Cross Sections

What you'll learn

After the first pin · 3 / 1112 min read
  • Point OpenMC at a nuclear data library, from the shell or from Python.
  • Control how OpenMC resolves a temperature that the library does not contain.
  • Explain what thermal scattering data does and which materials need it.
  • Inspect a nuclide with openmc.data to see the cross section behind the model.

Before you start

Understanding Cross Sections

Nuclear cross sections represent the probability of neutron interactions with atomic nuclei. Think of them as effective target areas - larger cross sections mean higher interaction probabilities. OpenMC uses these data to determine what happens when neutrons encounter different materials.

The unit is the barn, 10⁻²⁴ cm², which was chosen because it is roughly the geometric cross-sectional area of a heavy nucleus — so a cross section of a few barns means the nucleus behaves about as large as it looks, and the hundreds or thousands of barns that appear at thermal energies mean it behaves very much larger. Nothing about the physical size of the nucleus has changed; what changes is how long the neutron spends nearby, and how nearly its energy matches a quantum state the compound nucleus can occupy.

That is why the energy dependence is so violent, and why OpenMC works from continuous-energy data rather than group averages. Three regimes recur in every reactor calculation. At low energy, absorption cross sections rise as 1/v, simply because a slower neutron lingers longer in the vicinity of a nucleus. Through the electron-volt to kilo-electron-volt range, the resolved resonances appear — narrow spikes where the cross section jumps by orders of magnitude over a few electron-volts, and where the 6.67 eV resonance in U-238 alone accounts for a large share of the absorption in a light-water lattice. Above roughly a hundred kilo-electron-volts the resonances crowd together into a smoother curve, and inelastic scattering and threshold reactions open up.

Reactions are identified by MT number, an ENDF convention that OpenMC carries through into its own data classes and its tally scores. MT 1 is the total, MT 2 elastic scattering, MT 18 fission, and MT 102 radiative capture. Every cross section is specific to a single nuclide, not to an element — U-235 and U-238 differ by three orders of magnitude in thermal fission, which is the entire reason enrichment exists.

Nuclear Data Libraries

OpenMC requires nuclear data files that contain cross section information for all nuclides in your model. These libraries are based on evaluated nuclear data like ENDF/B-VIII.0 and are processed into HDF5 format for efficient access.

Getting Nuclear Data

python
# Download pre-built HDF5 nuclear data from https://openmc.org/data/
# (e.g. the ENDF/B-VIII.0 library, ~1.5 GB)

# After downloading, point OpenMC to the data:
import os
os.environ['OPENMC_CROSS_SECTIONS'] = '/path/to/cross_sections.xml'

# Or set it permanently in your shell profile:
# export OPENMC_CROSS_SECTIONS=/path/to/cross_sections.xml

Tutorial snippet — no separate file in examples repo

Using Custom Data Libraries

python
# Point to a specific cross sections file
import openmc

# Set cross sections path for all materials
openmc.config['cross_sections'] = '/path/to/cross_sections.xml'

# Or set for specific materials
materials = openmc.Materials()
materials.cross_sections = '/path/to/cross_sections.xml'

# Check what's available
print(f"Using cross sections: {openmc.config['cross_sections']}")

Tutorial snippet — no separate file in examples repo

Data Requirements: Every nuclide in your materials must have corresponding cross section data. OpenMC will tell you if any data is missing when you run a simulation.

Temperature Effects

Cross sections change with temperature due to thermal motion of nuclei (Doppler broadening). This effect is most important for resonance reactions and significantly impacts reactor physics calculations.

Basic Temperature Handling

python
fuel = openmc.Material(name='Hot Fuel')
fuel.set_density('g/cm3', 10.4)
fuel.add_nuclide('U235', 0.045)
fuel.add_nuclide('U238', 0.955)
fuel.add_element('O', 2.0)
fuel.temperature = 900  # Kelvin - default for every cell this fills

# A cell can override that default where it matters:
#   fuel_cell = openmc.Cell(fill=fuel, region=region)
#   fuel_cell.temperature = 950

# OpenMC will interpolate cross sections to this temperature
# (if data is available at multiple temperatures)

Tutorial snippet — no separate file in examples repo

Libraries are tabulated at a handful of temperatures, so the interesting question is what happens when a cell sits between two of them. The method entry decides: the default nearest snaps to the closest tabulated temperature and errors out if the closest is further away than tolerance, while interpolation loads both bracketing temperatures and interpolates between them.

python
settings = openmc.Settings()
settings.temperature = {
    'default': 293.6,
    'method': 'interpolation',   # 'nearest' is the default
    'tolerance': 10.0,           # how far 'nearest' may reach, in K
    'multipole': True,           # windowed multipole data, where available
}

Tutorial snippet — no separate file in examples repo

A temperature can be set in three places — on a cell, on a material, and as a global default — and they form a precedence chain in that order. The reason to care is U-238: broadening its resonances is the mechanism behind the fuel temperature coefficient, so a model that runs hot fuel on room-temperature data will report a reactivity that no real core would produce. Multiphysics coupling takes this further, into the case where the temperature is itself an output of the calculation.

Thermal Scattering

For materials containing light nuclei (like hydrogen in water), thermal neutron scattering is affected by molecular binding. OpenMC uses S(α,β) thermal scattering data to account for these effects accurately.

python
# Water with thermal scattering (essential for accuracy)
water = openmc.Material(name='Light Water')
water.set_density('g/cm3', 1.0)
water.add_nuclide('H1', 2.0)
water.add_element('O', 1.0)
water.add_s_alpha_beta('c_H_in_H2O')    # Critical for thermal neutrons

# Graphite moderator
graphite = openmc.Material(name='Graphite')
graphite.set_density('g/cm3', 1.7)
graphite.add_element('C', 1.0)
graphite.add_s_alpha_beta('c_Graphite')

# Available thermal scattering data:
# c_H_in_H2O    - hydrogen in water
# c_Graphite    - graphite
# c_Be          - beryllium metal
# c_BeO         - beryllium oxide
# ... and others in your nuclear data library

Tutorial snippet — no separate file in examples repo

Critical for Accuracy: Always include thermal scattering data for water, graphite, and other light-nucleus materials. Omitting this data can lead to significant errors in thermal neutron calculations.

Working with Cross Section Data

Basic Data Inspection

python
import openmc.data
import numpy as np
import matplotlib.pyplot as plt

# Load cross section data for a nuclide
u235_path = '/path/to/nuclear/data/U235.h5'
u235 = openmc.data.IncidentNeutron.from_hdf5(u235_path)

# Examine available reactions (keyed by MT number)
print("Available reactions for U-235:")
for mt, rxn in u235.reactions.items():
    print(f"  MT {mt}: {rxn}")

# Access the total cross section (MT 1) at a specific temperature
total_rxn = u235[1]                  # MT 1 = total
xs = total_rxn.xs['294K']           # Tabulated1D at 294 K

# Plot total cross section vs energy
energies = np.logspace(-2, 7, 1000)  # 0.01 eV to 10 MeV
total_xs = xs(energies)

plt.figure(figsize=(10, 6))
plt.loglog(energies, total_xs, 'b-', linewidth=2)
plt.xlabel('Energy (eV)')
plt.ylabel('Cross Section (barns)')
plt.title('U-235 Total Cross Section')
plt.grid(True, which='both', alpha=0.3)
plt.show()

# Check cross section at specific energy
thermal_energy = 0.0253  # eV (thermal)
fast_energy = 1e6       # eV (1 MeV)

print(f"U-235 total XS at thermal: {xs(thermal_energy):.1f} barns")
print(f"U-235 total XS at 1 MeV:   {xs(fast_energy):.1f} barns")

Tutorial snippet — no separate file in examples repo

Understanding Cross Section Behavior

python
# Compare different nuclides
u235 = openmc.data.IncidentNeutron.from_hdf5('/path/to/U235.h5')
u238 = openmc.data.IncidentNeutron.from_hdf5('/path/to/U238.h5')

energies = np.logspace(-2, 7, 1000)

# Access reactions by MT number at a specific temperature
u235_fission_xs = u235[18].xs['294K']   # MT 18 = fission
u235_total_xs   = u235[1].xs['294K']    # MT 1  = total
u238_total_xs   = u238[1].xs['294K']

plt.figure(figsize=(12, 8))

# Fission cross sections (U-235)
plt.subplot(2, 2, 1)
plt.loglog(energies, u235_fission_xs(energies), 'r-', label='U-235')
plt.xlabel('Energy (eV)')
plt.ylabel('Fission XS (barns)')
plt.title('Fission Cross Sections')
plt.legend()
plt.grid(True)

# Total cross sections comparison
plt.subplot(2, 2, 2)
plt.loglog(energies, u235_total_xs(energies), 'r-', label='U-235')
plt.loglog(energies, u238_total_xs(energies), 'b-', label='U-238')
plt.xlabel('Energy (eV)')
plt.ylabel('Total XS (barns)')
plt.title('Total Cross Sections')
plt.legend()
plt.grid(True)

plt.tight_layout()
plt.show()

# This helps explain why U-235 is fissile and U-238 has strong resonances

Tutorial snippet — no separate file in examples repo

What goes wrong, and how it announces itself

Data problems divide neatly into the ones that stop the run and the ones that do not, and only the first kind is harmless. A nuclide with no data in your library raises an error before transport begins, naming the nuclide — annoying, and self-correcting. A missing thermal scattering table raises nothing at all: the calculation runs, and the answer is wrong by several hundred pcm in a light-water lattice.

That asymmetry is worth a check of your own before a long run, since the material objects will happily tell you what they contain.

python
# Any material with a light moderating nucleus should carry a thermal
# scattering table. This is silent when it is wrong, so check it explicitly.
BOUND = {'H1': 'c_H_in_H2O', 'C0': 'c_Graphite', 'Be9': 'c_Be'}

for mat in model.materials:
    element = mat.to_xml_element()
    nuclides = {n.get('name') for n in element.findall('nuclide')}
    tables = {s.get('name') for s in element.findall('sab')}

    for nuclide, table in BOUND.items():
        if nuclide in nuclides and table not in tables:
            print(f'{mat.name or mat.id}: contains {nuclide} but no {table}')

Tutorial snippet — no separate file in examples repo

Beyond that, three habits prevent most of the remaining trouble. Use one library for a whole study and record which one, because a k computed against ENDF/B-VII.1 and one computed against VIII.0 can differ by more than the statistical uncertainty you worked to achieve — which makes the library a part of the result rather than a detail of the setup. Give temperatures that correspond to the state you mean to model, and let the nearest method's tolerance error catch you when the library cannot supply them. And when a result surprises you, look at the cross section itself rather than reasoning about it: openmc.data will plot the curve, and a great many puzzling reactivity results become obvious the moment the resonance structure is on screen.

Check yourself

  • Point OpenMC at a nuclear data library, from the shell or from Python?
  • Control how OpenMC resolves a temperature the library does not contain?
  • Explain what thermal scattering data does and which materials need it?