Skip to content

Keep the micro cross section caches addressed correctly - #4131

Open
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:claude/assert-xs-particle-type
Open

GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:claude/assert-xs-particle-type

Conversation

@GuySten

@GuySten GuySten commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Description

Each particle carries two microscopic cross section caches:

neutron_xs_.resize(data::nuclides.size());   // indexed by NUCLIDE
photon_xs_.resize(data::elements.size());    // indexed by ELEMENT

Photon data doesn't distinguish isotopes, so the two are addressed by different index spaces, and each applies to one particle type. None of that is stated or checked anywhere — and three places get it wrong. They look like separate bugs but they're one unexpressed invariant, so this fixes them together.

1. The photon cache is indexed with a nuclide index

p.photon_xs(i_nuclide) at four scoring sites (604, 628, 648, 1055). The index spaces coincide only when every element contributes exactly one nuclide. For UO2 — nuclides U235=0, U238=1, O16=2; elements U=0, O=1 — scoring U238 reads oxygen's cross section, and scoring O16 reads past the end of a size-2 vector.

A tally can also name a nuclide no material contains (Tally::set_nuclides calls openmc_load_nuclide for unknown names), which adds a nuclide without adding an element. So even a single-nuclide material goes out of bounds.

This adds data::nuclide_to_element, recorded in openmc_load_nuclide where the element is already being resolved, and routes all four lookups through one accessor:

const ElementMicroXS& photon_micro_xs(const Particle& p, int i_nuclide)
{
  return p.photon_xs(data::nuclide_to_element[i_nuclide]);
}

The mapping is filled on the same branch that reads the element's photon data, so it is populated exactly when data::elements is, holds C_NONE for a nuclide whose element was never loaded, and is cleared in free_memory_photon alongside data::elements. Reaching it therefore cannot outlive the data it indexes.

Particle::photon_xs had only a non-const overload, so scoring — which holds a const Particle& — could not have gone through a helper at all. Added the const one next to it; neutron_xs already had both.

2. The stale-cache refresh only handles neutrons

When the tallied nuclide is absent from the scoring region and multiply_density is off, the code refreshes the micro cross section cache — for neutrons only. A photon scored whatever energy it last collided at, since nothing had updated that element's entry since its last collision. Both estimators (score_tracklength_tally_general and score_collision_tally) now refresh the element cache too, guarded on last_E the way the collision path already does.

3. The refresh called the neutron routine for any particle

Particle::update_neutron_xs was called regardless of particle type. For a photon that evaluated a neutron cross section at a photon's energy and stored it where only neutron scoring reads, so the value was discarded. Guarded on the particle type, and the assumption is now asserted in the four routines that hold it: Material::calculate_neutron_xs, Material::calculate_photon_xs, Particle::update_neutron_xs, PhotonInteraction::calculate_xs.

Assertions rather than silent early returns: a caller asking for a neutron cross section while transporting a photon has a logic error, and returning quietly would cost a branch in the hottest loop in the code to make it permanently invisible — which is how this survived. They're free under NDEBUG, and since CI passes -DOPENMC_ENABLE_STRICT_FP=on, which strips -DNDEBUG from the RelWithDebInfo flags, they're live across the whole regression suite.

Note on reproducibility

Under temperature_method = 'interpolation' the discarded neutron evaluation consumed a pseudorandom number — Nuclide::calculate_xs samples between bracketing temperatures with if (f > prn(p.current_seed())) ++i_temp; (src/nuclide.cpp:809). Removing it shifts the stream, so a model combining photon transport, temperature interpolation and a nuclide-bin tally with multiply_density off will not reproduce earlier results bit for bit. The physics is unchanged — the discarded cross section was never read. No test in the suite combines those three.

Testing

tests/unit_tests/test_photon_micro_xs.py uses a data-independent invariant: two isotopes of the same element at the same atom density must score the same microscopic cross section, whatever that cross section is. The first test tallies U235 and U238 in a material holding both and requires the two bins to agree to rel=1e-12; before the fix U238 read the next element's entry, or past the end of the vector when there wasn't one. The second tallies U238 in a material of U235 only — a nuclide with no element of its own, whose index lands outside the cache entirely — and requires a non-zero score, which is the correct answer because the element's data is present either way.

No nuclear data was available in my environment, so those two tests could not be executed as written. The identical assertions were verified against a synthetic photon-only library on a branch that can run without neutron data:

  • Wrong element: C12 and C13 at equal density scored 0.006974 vs 0.015110, ratio 2.166668 — exactly Z(Al)/Z(C) = 13/6, i.e. C13 was reading aluminium. After the fix: identical to 12 significant figures.
  • Out of bounds: confirmed with a -D_GLIBCXX_ASSERTIONS build, which aborts on vector<ElementMicroXS>::operator[]: Assertion '__n < this->size()' failed. After the fix, clean.
  • Absent element: a microscopic tally on an element in no material now evaluates its cross section correctly instead of reading stale memory.
  • Element indexing end to end: for a material of C12, C13 and Al27 at densities 2:1:3 — where the nuclide and element numberings genuinely diverge — the macroscopic tally equals the sum over nuclide bins exactly for total, coherent, incoherent, photoelectric and pair production. C12/C13 = 2.000000 (same element, density ratio), and (Al27/3)/(C12/2) = 4.694444 = Z²(Al)/Z²(C).

C++ unit tests pass in a CI-configuration build (RelWithDebInfo, OPENMC_ENABLE_STRICT_FP=on, assertions confirmed live in compile_commands.json).

Checklist

  • I have performed a self-review of my own code
  • I have run clang-format (version 18) on any C++ source files (if applicable)
  • I have followed the style guidelines for Python source files (if applicable)
  • I have made corresponding changes to the documentation (if applicable)
  • I have added tests that prove my fix is effective or that my feature works (if applicable)

Material::calculate_xs dispatches on the particle type, so the neutron and
photon routines below it each have a particle type they assume. That assumption
is not written down anywhere and is not checked, and one caller does not hold
it: the micro cross section branch in score_tracklength_tally_general and its
collision counterpart call Particle::update_neutron_xs for whatever particle is
being scored, whenever the tallied nuclide is absent from the scoring region.

For a photon that evaluates a neutron cross section at a photon's energy and
stores it in a cache only neutron scoring ever reads, so the value is discarded
and the only trace is the work done to produce it. Guard those two call sites on
the particle type, and assert the assumption in the four routines that hold it:
Material::calculate_neutron_xs, Material::calculate_photon_xs,
Particle::update_neutron_xs and PhotonInteraction::calculate_xs.

Note that under temperature interpolation the discarded evaluation consumed a
pseudorandom number, since Nuclide::calculate_xs samples between the bracketing
temperatures. Removing it shifts the random number stream, so a model combining
photon transport, temperature interpolation and a nuclide bin tally with
multiply_density off will no longer reproduce earlier results bit for bit. No
test in the suite combines those.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxR1sQWWnALXj39rX12v7c
@GuySten GuySten added the Bugs label Sep 14, 2026
@GuySten
GuySten force-pushed the claude/assert-xs-particle-type branch 6 times, most recently from 047ad5c to e83a7ec Compare September 14, 2026 17:08
Each particle carries two micro cross section caches: neutron_xs_, sized by
data::nuclides, and photon_xs_, sized by data::elements because photon data does
not distinguish isotopes. Which particle type each applies to, and which index
space addresses it, are assumptions nothing states or checks, and three places
get them wrong.

Tally scoring indexes the photon cache with a nuclide index. The two index
spaces coincide only when every element contributes exactly one nuclide, so a
material of U235, U238 and O16 scores oxygen's cross section for U238 and reads
past the end of the vector for O16. A tally may also name a nuclide no material
contains, which adds a nuclide without adding an element, so even a single
nuclide material reaches out of bounds. Add data::nuclide_to_element, recorded
where the element is already being resolved, and resolve the nuclide once at the
top of score_general_ce_nonanalog, where the particle and the nuclide are fixed
for the whole call. The four lookups then address the cache by element index the
way every other caller in the tree already does, so there is no second way to
reach it and nothing new to get wrong. The two accessors name their parameters
i_element and i_nuclide rather than i.

The branch that refreshes a stale cache when the tallied nuclide is absent from
the scoring region only handles neutrons, so a photon scored whatever energy it
last collided at. Give it Particle::update_photon_xs, which refreshes an
element's entry unless it already holds the particle's energy. It takes an
element index rather than a nuclide one, unlike update_neutron_xs next to it,
because that is the index space photon data is tabulated in; the caller that
has a nuclide resolves it the same way scoring does.

That same branch called Particle::update_neutron_xs for whatever particle was
being scored. For a photon that evaluated a neutron cross section at a photon's
energy and stored it where only neutron scoring reads, so the value was
discarded. Guard it on the particle type, and assert the particle type in the
four routines that assume one: Material::calculate_neutron_xs,
Material::calculate_photon_xs, Particle::update_neutron_xs and
PhotonInteraction::calculate_xs.

The two estimators carried identical copies of that branch, so the guard would
have doubled it. Both now call one update_absent_nuclide_xs, which is shorter
than what either of them started with.

Note that under temperature interpolation the discarded neutron evaluation
consumed a pseudorandom number, since Nuclide::calculate_xs samples between the
bracketing temperatures. Removing it shifts the random number stream, so a model
combining photon transport, temperature interpolation and a nuclide bin tally
with multiply_density off will not reproduce earlier results bit for bit.

The absent nuclide test needs multiply_density off. An absent nuclide has an
atom density of zero, so with it on the score is zero whatever the cache held,
which is both a test that cannot fail for the right reason and the branch that
refreshes the cache in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxR1sQWWnALXj39rX12v7c
@GuySten
GuySten force-pushed the claude/assert-xs-particle-type branch from e83a7ec to e94fcbd Compare September 14, 2026 17:57
@GuySten
GuySten requested a review from paulromano September 14, 2026 18:21
@GuySten
GuySten marked this pull request as ready for review September 14, 2026 18:21
@GuySten
GuySten requested a review from amandalund as a code owner September 14, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants