Fix MaterialFromFilter dropping scores after the first collision in a cell - #4090

Open
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix
Open

Fix MaterialFromFilter dropping scores after the first collision in a cell#4090
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix

Conversation

@GuySten

@GuyStenGuySten commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fix MaterialFromFilter dropping scores after the first collision in a cell

This changes volumetric MaterialFromFilter results. Tallies using it were
previously missing scores; they will now be larger. Cell-to-cell partial
currents (a flux score with this filter) are unaffected. See below for why.

Problem

material_last was doing two unrelated jobs:

  1. the tally attribute read by MaterialFromFilter, and
  2. the cache key for the cross section lookup in event_calculate_xs().

Because a particle's energy changes at every collision, event_collide() forced
a cache miss by setting material_last() = C_NONE. From the second collision in
a cell onward the particle therefore carried material_last == C_NONE, which
matches no filter bin, so those scores silently vanished from volumetric
MaterialFromFilter tallies. Cell-to-cell partial currents (a flux score with
one of these filters) are scored at the crossing itself, where the previous
material was still correct, and were not affected.

cell_last is not reset on collision, so the equivalent CellFromFilter tally
counted those scores, and the two decompositions of the same score disagreed.

The effect is largest where it is least visible: a thick, scattering-dominated
cell loses nearly everything after the first collision.

MWE

import openmc
SHELL_THICKNESS = 10.0 # cm of iron; raise it to make the shortfall worse
water = openmc.Material()
water.set_density('g/cm3', 1.0)
water.add_nuclide('H1', 2.0)
water.add_nuclide('O16', 1.0)
iron = openmc.Material()
iron.set_density('g/cm3', 7.87)
iron.add_nuclide('Fe56', 1.0)
inner_surf = openmc.Sphere(r=5.0)
outer_surf = openmc.Sphere(r=5.0 + SHELL_THICKNESS, boundary_type='vacuum')
inner = openmc.Cell(fill=water, region=-inner_surf)
outer = openmc.Cell(fill=iron, region=+inner_surf & -outer_surf)
model = openmc.Model()
model.geometry = openmc.Geometry([inner, outer])
model.settings.run_mode = 'fixed source'
model.settings.batches = 10
model.settings.particles = 2000
model.settings.source = openmc.IndependentSource(
space=openmc.stats.Point((0.0, 0.0, 0.0)),
energy=openmc.stats.delta_function(2.0e6),
)
in_outer = openmc.CellFilter([outer])
total = openmc.Tally(name='total')
total.filters = [in_outer]
total.scores = ['total']
by_cell = openmc.Tally(name='by cell')
by_cell.filters = [in_outer, openmc.CellFromFilter([inner, outer])]
by_cell.scores = ['total']
by_material = openmc.Tally(name='by material')
by_material.filters = [in_outer, openmc.MaterialFromFilter([water, iron])]
by_material.scores = ['total']
model.tallies = openmc.Tallies([total, by_cell, by_material])
with openmc.StatePoint(model.run(output=False)) as sp:
t = sp.get_tally(name='total').mean.sum()
c = sp.get_tally(name='by cell').mean.sum()
m = sp.get_tally(name='by material').mean.sum()
print(f"\n reaction rate in outer cell {t:12.6f}")
print(f" summed over CellFromFilter {c:12.6f}"
f" ({100 * c / t:6.2f}% of total)")
print(f" summed over MaterialFromFilter {m:12.6f}"
f" ({100 * m / t:6.2f}% of total)")
print("\n Both decompositions should equal the total exactly.")
if abs(m - t) > 1e-9 * t:
print(f" MaterialFromFilter is short by {100 * (1 - m / t):.1f}%.")
else:
print(" Both match.")

Changes

  • material_last is renamed to material_xs_cache, which is what it actually
    is. It is written in exactly the same places as before, so cross section
    caching behaviour is unchanged.
  • MaterialFromFilter now derives the material from cell_last at the lowest
    coordinate level instead of tracking it separately. The two "from" filters
    read one source of truth and cannot drift apart again.
  • Adds cell_instance_last, maintained on the same cadence as cell_last. A
    cell with distributed materials resolves a different material per instance, so
    the cell index alone does not determine which material a particle came from.
  • Documents the semantics of cell_last, cell_instance_last and
    material_xs_cache on their accessors, including that the first two are
    deliberately not updated on collision and that the last must never be used as
    a tally attribute.

This deliberately leaves event_calculate_xs() alone. Rerouting the cache key
was the alternative; deriving the material instead means there is no cross
section behaviour to re-verify.

Testing

tests/unit_tests/test_filter_material_from.py:

  • A water sphere inside an iron shell, one material per cell so the two
    decompositions are the same partition. Asserts both sum to the undecomposed
    total and agree bin for bin. The material sum falls well short of the total
    before this change.
  • A 2x2 lattice of one repeated cell carrying four distributed materials,
    asserting all four "from" bins are populated. Resolving the fill without the
    instance would put the whole total in the first bin.
  • A cell-to-cell partial current (flux score), asserting the material and cell
    decompositions still agree. cell_last and cell_instance_last are saved at
    the top of event_cross_surface(), before the crossing, so the derived filter
    sees the same thing the tracked attribute used to.

Both are per-history identities within a single run, so they hold to round-off
rather than statistically.

tests/regression_tests/surface_tally uses MaterialFromFilter with a flux
score, which makes it a partial current rather than a volumetric tally, so its
stored results should be unchanged. A unit test covers that path so the change
is shown not to regress it.

Notes

material_last was stale at birth as well: a fresh particle inherited whatever
the reused Particle object last held, since find_cell() writes the
pre-existing material before overwriting it. Deriving from cell_last, which is
initialised to the particle's own cells at birth, removes that too.

This is the material_last portion of #3849, split out as suggested in review
there. It does not change cell_last semantics.

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)

@GuySten
GuySten marked this pull request as ready for review August 31, 2026 18:45
Sign up for freeto 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.

1 participant

@GuySten
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Fix MaterialFromFilter dropping scores after the first collision in a cell - #4090

Open
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix
Open

Fix MaterialFromFilter dropping scores after the first collision in a cell#4090
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix

Conversation

@GuySten

@GuyStenGuySten commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fix MaterialFromFilter dropping scores after the first collision in a cell

This changes volumetric MaterialFromFilter results. Tallies using it were
previously missing scores; they will now be larger. Cell-to-cell partial
currents (a flux score with this filter) are unaffected. See below for why.

Problem

material_last was doing two unrelated jobs:

  1. the tally attribute read by MaterialFromFilter, and
  2. the cache key for the cross section lookup in event_calculate_xs().

Because a particle's energy changes at every collision, event_collide() forced
a cache miss by setting material_last() = C_NONE. From the second collision in
a cell onward the particle therefore carried material_last == C_NONE, which
matches no filter bin, so those scores silently vanished from volumetric
MaterialFromFilter tallies. Cell-to-cell partial currents (a flux score with
one of these filters) are scored at the crossing itself, where the previous
material was still correct, and were not affected.

cell_last is not reset on collision, so the equivalent CellFromFilter tally
counted those scores, and the two decompositions of the same score disagreed.

The effect is largest where it is least visible: a thick, scattering-dominated
cell loses nearly everything after the first collision.

MWE

import openmc
SHELL_THICKNESS = 10.0 # cm of iron; raise it to make the shortfall worse
water = openmc.Material()
water.set_density('g/cm3', 1.0)
water.add_nuclide('H1', 2.0)
water.add_nuclide('O16', 1.0)
iron = openmc.Material()
iron.set_density('g/cm3', 7.87)
iron.add_nuclide('Fe56', 1.0)
inner_surf = openmc.Sphere(r=5.0)
outer_surf = openmc.Sphere(r=5.0 + SHELL_THICKNESS, boundary_type='vacuum')
inner = openmc.Cell(fill=water, region=-inner_surf)
outer = openmc.Cell(fill=iron, region=+inner_surf & -outer_surf)
model = openmc.Model()
model.geometry = openmc.Geometry([inner, outer])
model.settings.run_mode = 'fixed source'
model.settings.batches = 10
model.settings.particles = 2000
model.settings.source = openmc.IndependentSource(
space=openmc.stats.Point((0.0, 0.0, 0.0)),
energy=openmc.stats.delta_function(2.0e6),
)
in_outer = openmc.CellFilter([outer])
total = openmc.Tally(name='total')
total.filters = [in_outer]
total.scores = ['total']
by_cell = openmc.Tally(name='by cell')
by_cell.filters = [in_outer, openmc.CellFromFilter([inner, outer])]
by_cell.scores = ['total']
by_material = openmc.Tally(name='by material')
by_material.filters = [in_outer, openmc.MaterialFromFilter([water, iron])]
by_material.scores = ['total']
model.tallies = openmc.Tallies([total, by_cell, by_material])
with openmc.StatePoint(model.run(output=False)) as sp:
t = sp.get_tally(name='total').mean.sum()
c = sp.get_tally(name='by cell').mean.sum()
m = sp.get_tally(name='by material').mean.sum()
print(f"\n reaction rate in outer cell {t:12.6f}")
print(f" summed over CellFromFilter {c:12.6f}"
f" ({100 * c / t:6.2f}% of total)")
print(f" summed over MaterialFromFilter {m:12.6f}"
f" ({100 * m / t:6.2f}% of total)")
print("\n Both decompositions should equal the total exactly.")
if abs(m - t) > 1e-9 * t:
print(f" MaterialFromFilter is short by {100 * (1 - m / t):.1f}%.")
else:
print(" Both match.")

Changes

  • material_last is renamed to material_xs_cache, which is what it actually
    is. It is written in exactly the same places as before, so cross section
    caching behaviour is unchanged.
  • MaterialFromFilter now derives the material from cell_last at the lowest
    coordinate level instead of tracking it separately. The two "from" filters
    read one source of truth and cannot drift apart again.
  • Adds cell_instance_last, maintained on the same cadence as cell_last. A
    cell with distributed materials resolves a different material per instance, so
    the cell index alone does not determine which material a particle came from.
  • Documents the semantics of cell_last, cell_instance_last and
    material_xs_cache on their accessors, including that the first two are
    deliberately not updated on collision and that the last must never be used as
    a tally attribute.

This deliberately leaves event_calculate_xs() alone. Rerouting the cache key
was the alternative; deriving the material instead means there is no cross
section behaviour to re-verify.

Testing

tests/unit_tests/test_filter_material_from.py:

  • A water sphere inside an iron shell, one material per cell so the two
    decompositions are the same partition. Asserts both sum to the undecomposed
    total and agree bin for bin. The material sum falls well short of the total
    before this change.
  • A 2x2 lattice of one repeated cell carrying four distributed materials,
    asserting all four "from" bins are populated. Resolving the fill without the
    instance would put the whole total in the first bin.
  • A cell-to-cell partial current (flux score), asserting the material and cell
    decompositions still agree. cell_last and cell_instance_last are saved at
    the top of event_cross_surface(), before the crossing, so the derived filter
    sees the same thing the tracked attribute used to.

Both are per-history identities within a single run, so they hold to round-off
rather than statistically.

tests/regression_tests/surface_tally uses MaterialFromFilter with a flux
score, which makes it a partial current rather than a volumetric tally, so its
stored results should be unchanged. A unit test covers that path so the change
is shown not to regress it.

Notes

material_last was stale at birth as well: a fresh particle inherited whatever
the reused Particle object last held, since find_cell() writes the
pre-existing material before overwriting it. Deriving from cell_last, which is
initialised to the particle's own cells at birth, removes that too.

This is the material_last portion of #3849, split out as suggested in review
there. It does not change cell_last semantics.

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)

@GuySten
GuySten marked this pull request as ready for review August 31, 2026 18:45
Sign up for freeto 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.

1 participant

@GuySten
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix MaterialFromFilter dropping scores after the first collision in a cell - #4090

Open
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix
Open

Fix MaterialFromFilter dropping scores after the first collision in a cell#4090
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix

Conversation

@GuySten

@GuyStenGuySten commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fix MaterialFromFilter dropping scores after the first collision in a cell

This changes volumetric MaterialFromFilter results. Tallies using it were
previously missing scores; they will now be larger. Cell-to-cell partial
currents (a flux score with this filter) are unaffected. See below for why.

Problem

material_last was doing two unrelated jobs:

  1. the tally attribute read by MaterialFromFilter, and
  2. the cache key for the cross section lookup in event_calculate_xs().

Because a particle's energy changes at every collision, event_collide() forced
a cache miss by setting material_last() = C_NONE. From the second collision in
a cell onward the particle therefore carried material_last == C_NONE, which
matches no filter bin, so those scores silently vanished from volumetric
MaterialFromFilter tallies. Cell-to-cell partial currents (a flux score with
one of these filters) are scored at the crossing itself, where the previous
material was still correct, and were not affected.

cell_last is not reset on collision, so the equivalent CellFromFilter tally
counted those scores, and the two decompositions of the same score disagreed.

The effect is largest where it is least visible: a thick, scattering-dominated
cell loses nearly everything after the first collision.

MWE

import openmc
SHELL_THICKNESS = 10.0 # cm of iron; raise it to make the shortfall worse
water = openmc.Material()
water.set_density('g/cm3', 1.0)
water.add_nuclide('H1', 2.0)
water.add_nuclide('O16', 1.0)
iron = openmc.Material()
iron.set_density('g/cm3', 7.87)
iron.add_nuclide('Fe56', 1.0)
inner_surf = openmc.Sphere(r=5.0)
outer_surf = openmc.Sphere(r=5.0 + SHELL_THICKNESS, boundary_type='vacuum')
inner = openmc.Cell(fill=water, region=-inner_surf)
outer = openmc.Cell(fill=iron, region=+inner_surf & -outer_surf)
model = openmc.Model()
model.geometry = openmc.Geometry([inner, outer])
model.settings.run_mode = 'fixed source'
model.settings.batches = 10
model.settings.particles = 2000
model.settings.source = openmc.IndependentSource(
space=openmc.stats.Point((0.0, 0.0, 0.0)),
energy=openmc.stats.delta_function(2.0e6),
)
in_outer = openmc.CellFilter([outer])
total = openmc.Tally(name='total')
total.filters = [in_outer]
total.scores = ['total']
by_cell = openmc.Tally(name='by cell')
by_cell.filters = [in_outer, openmc.CellFromFilter([inner, outer])]
by_cell.scores = ['total']
by_material = openmc.Tally(name='by material')
by_material.filters = [in_outer, openmc.MaterialFromFilter([water, iron])]
by_material.scores = ['total']
model.tallies = openmc.Tallies([total, by_cell, by_material])
with openmc.StatePoint(model.run(output=False)) as sp:
t = sp.get_tally(name='total').mean.sum()
c = sp.get_tally(name='by cell').mean.sum()
m = sp.get_tally(name='by material').mean.sum()
print(f"\n reaction rate in outer cell {t:12.6f}")
print(f" summed over CellFromFilter {c:12.6f}"
f" ({100 * c / t:6.2f}% of total)")
print(f" summed over MaterialFromFilter {m:12.6f}"
f" ({100 * m / t:6.2f}% of total)")
print("\n Both decompositions should equal the total exactly.")
if abs(m - t) > 1e-9 * t:
print(f" MaterialFromFilter is short by {100 * (1 - m / t):.1f}%.")
else:
print(" Both match.")

Changes

  • material_last is renamed to material_xs_cache, which is what it actually
    is. It is written in exactly the same places as before, so cross section
    caching behaviour is unchanged.
  • MaterialFromFilter now derives the material from cell_last at the lowest
    coordinate level instead of tracking it separately. The two "from" filters
    read one source of truth and cannot drift apart again.
  • Adds cell_instance_last, maintained on the same cadence as cell_last. A
    cell with distributed materials resolves a different material per instance, so
    the cell index alone does not determine which material a particle came from.
  • Documents the semantics of cell_last, cell_instance_last and
    material_xs_cache on their accessors, including that the first two are
    deliberately not updated on collision and that the last must never be used as
    a tally attribute.

This deliberately leaves event_calculate_xs() alone. Rerouting the cache key
was the alternative; deriving the material instead means there is no cross
section behaviour to re-verify.

Testing

tests/unit_tests/test_filter_material_from.py:

  • A water sphere inside an iron shell, one material per cell so the two
    decompositions are the same partition. Asserts both sum to the undecomposed
    total and agree bin for bin. The material sum falls well short of the total
    before this change.
  • A 2x2 lattice of one repeated cell carrying four distributed materials,
    asserting all four "from" bins are populated. Resolving the fill without the
    instance would put the whole total in the first bin.
  • A cell-to-cell partial current (flux score), asserting the material and cell
    decompositions still agree. cell_last and cell_instance_last are saved at
    the top of event_cross_surface(), before the crossing, so the derived filter
    sees the same thing the tracked attribute used to.

Both are per-history identities within a single run, so they hold to round-off
rather than statistically.

tests/regression_tests/surface_tally uses MaterialFromFilter with a flux
score, which makes it a partial current rather than a volumetric tally, so its
stored results should be unchanged. A unit test covers that path so the change
is shown not to regress it.

Notes

material_last was stale at birth as well: a fresh particle inherited whatever
the reused Particle object last held, since find_cell() writes the
pre-existing material before overwriting it. Deriving from cell_last, which is
initialised to the particle's own cells at birth, removes that too.

This is the material_last portion of #3849, split out as suggested in review
there. It does not change cell_last semantics.

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)

@GuySten
GuySten marked this pull request as ready for review August 31, 2026 18:45
Sign up for freeto 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.

1 participant

@GuySten
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix MaterialFromFilter dropping scores after the first collision in a cell - #4090

Open
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix
Open

Fix MaterialFromFilter dropping scores after the first collision in a cell#4090
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix

Conversation

@GuySten

@GuyStenGuySten commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fix MaterialFromFilter dropping scores after the first collision in a cell

This changes volumetric MaterialFromFilter results. Tallies using it were
previously missing scores; they will now be larger. Cell-to-cell partial
currents (a flux score with this filter) are unaffected. See below for why.

Problem

material_last was doing two unrelated jobs:

  1. the tally attribute read by MaterialFromFilter, and
  2. the cache key for the cross section lookup in event_calculate_xs().

Because a particle's energy changes at every collision, event_collide() forced
a cache miss by setting material_last() = C_NONE. From the second collision in
a cell onward the particle therefore carried material_last == C_NONE, which
matches no filter bin, so those scores silently vanished from volumetric
MaterialFromFilter tallies. Cell-to-cell partial currents (a flux score with
one of these filters) are scored at the crossing itself, where the previous
material was still correct, and were not affected.

cell_last is not reset on collision, so the equivalent CellFromFilter tally
counted those scores, and the two decompositions of the same score disagreed.

The effect is largest where it is least visible: a thick, scattering-dominated
cell loses nearly everything after the first collision.

MWE

import openmc
SHELL_THICKNESS = 10.0 # cm of iron; raise it to make the shortfall worse
water = openmc.Material()
water.set_density('g/cm3', 1.0)
water.add_nuclide('H1', 2.0)
water.add_nuclide('O16', 1.0)
iron = openmc.Material()
iron.set_density('g/cm3', 7.87)
iron.add_nuclide('Fe56', 1.0)
inner_surf = openmc.Sphere(r=5.0)
outer_surf = openmc.Sphere(r=5.0 + SHELL_THICKNESS, boundary_type='vacuum')
inner = openmc.Cell(fill=water, region=-inner_surf)
outer = openmc.Cell(fill=iron, region=+inner_surf & -outer_surf)
model = openmc.Model()
model.geometry = openmc.Geometry([inner, outer])
model.settings.run_mode = 'fixed source'
model.settings.batches = 10
model.settings.particles = 2000
model.settings.source = openmc.IndependentSource(
space=openmc.stats.Point((0.0, 0.0, 0.0)),
energy=openmc.stats.delta_function(2.0e6),
)
in_outer = openmc.CellFilter([outer])
total = openmc.Tally(name='total')
total.filters = [in_outer]
total.scores = ['total']
by_cell = openmc.Tally(name='by cell')
by_cell.filters = [in_outer, openmc.CellFromFilter([inner, outer])]
by_cell.scores = ['total']
by_material = openmc.Tally(name='by material')
by_material.filters = [in_outer, openmc.MaterialFromFilter([water, iron])]
by_material.scores = ['total']
model.tallies = openmc.Tallies([total, by_cell, by_material])
with openmc.StatePoint(model.run(output=False)) as sp:
t = sp.get_tally(name='total').mean.sum()
c = sp.get_tally(name='by cell').mean.sum()
m = sp.get_tally(name='by material').mean.sum()
print(f"\n reaction rate in outer cell {t:12.6f}")
print(f" summed over CellFromFilter {c:12.6f}"
f" ({100 * c / t:6.2f}% of total)")
print(f" summed over MaterialFromFilter {m:12.6f}"
f" ({100 * m / t:6.2f}% of total)")
print("\n Both decompositions should equal the total exactly.")
if abs(m - t) > 1e-9 * t:
print(f" MaterialFromFilter is short by {100 * (1 - m / t):.1f}%.")
else:
print(" Both match.")

Changes

  • material_last is renamed to material_xs_cache, which is what it actually
    is. It is written in exactly the same places as before, so cross section
    caching behaviour is unchanged.
  • MaterialFromFilter now derives the material from cell_last at the lowest
    coordinate level instead of tracking it separately. The two "from" filters
    read one source of truth and cannot drift apart again.
  • Adds cell_instance_last, maintained on the same cadence as cell_last. A
    cell with distributed materials resolves a different material per instance, so
    the cell index alone does not determine which material a particle came from.
  • Documents the semantics of cell_last, cell_instance_last and
    material_xs_cache on their accessors, including that the first two are
    deliberately not updated on collision and that the last must never be used as
    a tally attribute.

This deliberately leaves event_calculate_xs() alone. Rerouting the cache key
was the alternative; deriving the material instead means there is no cross
section behaviour to re-verify.

Testing

tests/unit_tests/test_filter_material_from.py:

  • A water sphere inside an iron shell, one material per cell so the two
    decompositions are the same partition. Asserts both sum to the undecomposed
    total and agree bin for bin. The material sum falls well short of the total
    before this change.
  • A 2x2 lattice of one repeated cell carrying four distributed materials,
    asserting all four "from" bins are populated. Resolving the fill without the
    instance would put the whole total in the first bin.
  • A cell-to-cell partial current (flux score), asserting the material and cell
    decompositions still agree. cell_last and cell_instance_last are saved at
    the top of event_cross_surface(), before the crossing, so the derived filter
    sees the same thing the tracked attribute used to.

Both are per-history identities within a single run, so they hold to round-off
rather than statistically.

tests/regression_tests/surface_tally uses MaterialFromFilter with a flux
score, which makes it a partial current rather than a volumetric tally, so its
stored results should be unchanged. A unit test covers that path so the change
is shown not to regress it.

Notes

material_last was stale at birth as well: a fresh particle inherited whatever
the reused Particle object last held, since find_cell() writes the
pre-existing material before overwriting it. Deriving from cell_last, which is
initialised to the particle's own cells at birth, removes that too.

This is the material_last portion of #3849, split out as suggested in review
there. It does not change cell_last semantics.

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)

@GuySten
GuySten marked this pull request as ready for review August 31, 2026 18:45
Sign up for freeto 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.

1 participant

@GuySten
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Fix MaterialFromFilter dropping scores after the first collision in a cell - #4090

Open
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix
Open

Fix MaterialFromFilter dropping scores after the first collision in a cell#4090
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix

Conversation

@GuySten

@GuyStenGuySten commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fix MaterialFromFilter dropping scores after the first collision in a cell

This changes volumetric MaterialFromFilter results. Tallies using it were
previously missing scores; they will now be larger. Cell-to-cell partial
currents (a flux score with this filter) are unaffected. See below for why.

Problem

material_last was doing two unrelated jobs:

  1. the tally attribute read by MaterialFromFilter, and
  2. the cache key for the cross section lookup in event_calculate_xs().

Because a particle's energy changes at every collision, event_collide() forced
a cache miss by setting material_last() = C_NONE. From the second collision in
a cell onward the particle therefore carried material_last == C_NONE, which
matches no filter bin, so those scores silently vanished from volumetric
MaterialFromFilter tallies. Cell-to-cell partial currents (a flux score with
one of these filters) are scored at the crossing itself, where the previous
material was still correct, and were not affected.

cell_last is not reset on collision, so the equivalent CellFromFilter tally
counted those scores, and the two decompositions of the same score disagreed.

The effect is largest where it is least visible: a thick, scattering-dominated
cell loses nearly everything after the first collision.

MWE

import openmc
SHELL_THICKNESS = 10.0 # cm of iron; raise it to make the shortfall worse
water = openmc.Material()
water.set_density('g/cm3', 1.0)
water.add_nuclide('H1', 2.0)
water.add_nuclide('O16', 1.0)
iron = openmc.Material()
iron.set_density('g/cm3', 7.87)
iron.add_nuclide('Fe56', 1.0)
inner_surf = openmc.Sphere(r=5.0)
outer_surf = openmc.Sphere(r=5.0 + SHELL_THICKNESS, boundary_type='vacuum')
inner = openmc.Cell(fill=water, region=-inner_surf)
outer = openmc.Cell(fill=iron, region=+inner_surf & -outer_surf)
model = openmc.Model()
model.geometry = openmc.Geometry([inner, outer])
model.settings.run_mode = 'fixed source'
model.settings.batches = 10
model.settings.particles = 2000
model.settings.source = openmc.IndependentSource(
space=openmc.stats.Point((0.0, 0.0, 0.0)),
energy=openmc.stats.delta_function(2.0e6),
)
in_outer = openmc.CellFilter([outer])
total = openmc.Tally(name='total')
total.filters = [in_outer]
total.scores = ['total']
by_cell = openmc.Tally(name='by cell')
by_cell.filters = [in_outer, openmc.CellFromFilter([inner, outer])]
by_cell.scores = ['total']
by_material = openmc.Tally(name='by material')
by_material.filters = [in_outer, openmc.MaterialFromFilter([water, iron])]
by_material.scores = ['total']
model.tallies = openmc.Tallies([total, by_cell, by_material])
with openmc.StatePoint(model.run(output=False)) as sp:
t = sp.get_tally(name='total').mean.sum()
c = sp.get_tally(name='by cell').mean.sum()
m = sp.get_tally(name='by material').mean.sum()
print(f"\n reaction rate in outer cell {t:12.6f}")
print(f" summed over CellFromFilter {c:12.6f}"
f" ({100 * c / t:6.2f}% of total)")
print(f" summed over MaterialFromFilter {m:12.6f}"
f" ({100 * m / t:6.2f}% of total)")
print("\n Both decompositions should equal the total exactly.")
if abs(m - t) > 1e-9 * t:
print(f" MaterialFromFilter is short by {100 * (1 - m / t):.1f}%.")
else:
print(" Both match.")

Changes

  • material_last is renamed to material_xs_cache, which is what it actually
    is. It is written in exactly the same places as before, so cross section
    caching behaviour is unchanged.
  • MaterialFromFilter now derives the material from cell_last at the lowest
    coordinate level instead of tracking it separately. The two "from" filters
    read one source of truth and cannot drift apart again.
  • Adds cell_instance_last, maintained on the same cadence as cell_last. A
    cell with distributed materials resolves a different material per instance, so
    the cell index alone does not determine which material a particle came from.
  • Documents the semantics of cell_last, cell_instance_last and
    material_xs_cache on their accessors, including that the first two are
    deliberately not updated on collision and that the last must never be used as
    a tally attribute.

This deliberately leaves event_calculate_xs() alone. Rerouting the cache key
was the alternative; deriving the material instead means there is no cross
section behaviour to re-verify.

Testing

tests/unit_tests/test_filter_material_from.py:

  • A water sphere inside an iron shell, one material per cell so the two
    decompositions are the same partition. Asserts both sum to the undecomposed
    total and agree bin for bin. The material sum falls well short of the total
    before this change.
  • A 2x2 lattice of one repeated cell carrying four distributed materials,
    asserting all four "from" bins are populated. Resolving the fill without the
    instance would put the whole total in the first bin.
  • A cell-to-cell partial current (flux score), asserting the material and cell
    decompositions still agree. cell_last and cell_instance_last are saved at
    the top of event_cross_surface(), before the crossing, so the derived filter
    sees the same thing the tracked attribute used to.

Both are per-history identities within a single run, so they hold to round-off
rather than statistically.

tests/regression_tests/surface_tally uses MaterialFromFilter with a flux
score, which makes it a partial current rather than a volumetric tally, so its
stored results should be unchanged. A unit test covers that path so the change
is shown not to regress it.

Notes

material_last was stale at birth as well: a fresh particle inherited whatever
the reused Particle object last held, since find_cell() writes the
pre-existing material before overwriting it. Deriving from cell_last, which is
initialised to the particle's own cells at birth, removes that too.

This is the material_last portion of #3849, split out as suggested in review
there. It does not change cell_last semantics.

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)

@GuySten
GuySten marked this pull request as ready for review August 31, 2026 18:45
Sign up for freeto 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.

1 participant

@GuySten
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix MaterialFromFilter dropping scores after the first collision in a cell - #4090

Open
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix
Open

Fix MaterialFromFilter dropping scores after the first collision in a cell#4090
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix

Conversation

@GuySten

@GuyStenGuySten commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fix MaterialFromFilter dropping scores after the first collision in a cell

This changes volumetric MaterialFromFilter results. Tallies using it were
previously missing scores; they will now be larger. Cell-to-cell partial
currents (a flux score with this filter) are unaffected. See below for why.

Problem

material_last was doing two unrelated jobs:

  1. the tally attribute read by MaterialFromFilter, and
  2. the cache key for the cross section lookup in event_calculate_xs().

Because a particle's energy changes at every collision, event_collide() forced
a cache miss by setting material_last() = C_NONE. From the second collision in
a cell onward the particle therefore carried material_last == C_NONE, which
matches no filter bin, so those scores silently vanished from volumetric
MaterialFromFilter tallies. Cell-to-cell partial currents (a flux score with
one of these filters) are scored at the crossing itself, where the previous
material was still correct, and were not affected.

cell_last is not reset on collision, so the equivalent CellFromFilter tally
counted those scores, and the two decompositions of the same score disagreed.

The effect is largest where it is least visible: a thick, scattering-dominated
cell loses nearly everything after the first collision.

MWE

import openmc
SHELL_THICKNESS = 10.0 # cm of iron; raise it to make the shortfall worse
water = openmc.Material()
water.set_density('g/cm3', 1.0)
water.add_nuclide('H1', 2.0)
water.add_nuclide('O16', 1.0)
iron = openmc.Material()
iron.set_density('g/cm3', 7.87)
iron.add_nuclide('Fe56', 1.0)
inner_surf = openmc.Sphere(r=5.0)
outer_surf = openmc.Sphere(r=5.0 + SHELL_THICKNESS, boundary_type='vacuum')
inner = openmc.Cell(fill=water, region=-inner_surf)
outer = openmc.Cell(fill=iron, region=+inner_surf & -outer_surf)
model = openmc.Model()
model.geometry = openmc.Geometry([inner, outer])
model.settings.run_mode = 'fixed source'
model.settings.batches = 10
model.settings.particles = 2000
model.settings.source = openmc.IndependentSource(
space=openmc.stats.Point((0.0, 0.0, 0.0)),
energy=openmc.stats.delta_function(2.0e6),
)
in_outer = openmc.CellFilter([outer])
total = openmc.Tally(name='total')
total.filters = [in_outer]
total.scores = ['total']
by_cell = openmc.Tally(name='by cell')
by_cell.filters = [in_outer, openmc.CellFromFilter([inner, outer])]
by_cell.scores = ['total']
by_material = openmc.Tally(name='by material')
by_material.filters = [in_outer, openmc.MaterialFromFilter([water, iron])]
by_material.scores = ['total']
model.tallies = openmc.Tallies([total, by_cell, by_material])
with openmc.StatePoint(model.run(output=False)) as sp:
t = sp.get_tally(name='total').mean.sum()
c = sp.get_tally(name='by cell').mean.sum()
m = sp.get_tally(name='by material').mean.sum()
print(f"\n reaction rate in outer cell {t:12.6f}")
print(f" summed over CellFromFilter {c:12.6f}"
f" ({100 * c / t:6.2f}% of total)")
print(f" summed over MaterialFromFilter {m:12.6f}"
f" ({100 * m / t:6.2f}% of total)")
print("\n Both decompositions should equal the total exactly.")
if abs(m - t) > 1e-9 * t:
print(f" MaterialFromFilter is short by {100 * (1 - m / t):.1f}%.")
else:
print(" Both match.")

Changes

  • material_last is renamed to material_xs_cache, which is what it actually
    is. It is written in exactly the same places as before, so cross section
    caching behaviour is unchanged.
  • MaterialFromFilter now derives the material from cell_last at the lowest
    coordinate level instead of tracking it separately. The two "from" filters
    read one source of truth and cannot drift apart again.
  • Adds cell_instance_last, maintained on the same cadence as cell_last. A
    cell with distributed materials resolves a different material per instance, so
    the cell index alone does not determine which material a particle came from.
  • Documents the semantics of cell_last, cell_instance_last and
    material_xs_cache on their accessors, including that the first two are
    deliberately not updated on collision and that the last must never be used as
    a tally attribute.

This deliberately leaves event_calculate_xs() alone. Rerouting the cache key
was the alternative; deriving the material instead means there is no cross
section behaviour to re-verify.

Testing

tests/unit_tests/test_filter_material_from.py:

  • A water sphere inside an iron shell, one material per cell so the two
    decompositions are the same partition. Asserts both sum to the undecomposed
    total and agree bin for bin. The material sum falls well short of the total
    before this change.
  • A 2x2 lattice of one repeated cell carrying four distributed materials,
    asserting all four "from" bins are populated. Resolving the fill without the
    instance would put the whole total in the first bin.
  • A cell-to-cell partial current (flux score), asserting the material and cell
    decompositions still agree. cell_last and cell_instance_last are saved at
    the top of event_cross_surface(), before the crossing, so the derived filter
    sees the same thing the tracked attribute used to.

Both are per-history identities within a single run, so they hold to round-off
rather than statistically.

tests/regression_tests/surface_tally uses MaterialFromFilter with a flux
score, which makes it a partial current rather than a volumetric tally, so its
stored results should be unchanged. A unit test covers that path so the change
is shown not to regress it.

Notes

material_last was stale at birth as well: a fresh particle inherited whatever
the reused Particle object last held, since find_cell() writes the
pre-existing material before overwriting it. Deriving from cell_last, which is
initialised to the particle's own cells at birth, removes that too.

This is the material_last portion of #3849, split out as suggested in review
there. It does not change cell_last semantics.

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)

@GuySten
GuySten marked this pull request as ready for review August 31, 2026 18:45
Sign up for freeto 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.

1 participant

@GuySten
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix MaterialFromFilter dropping scores after the first collision in a cell - #4090

Open
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix
Open

Fix MaterialFromFilter dropping scores after the first collision in a cell#4090
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix

Conversation

@GuySten

@GuyStenGuySten commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fix MaterialFromFilter dropping scores after the first collision in a cell

This changes volumetric MaterialFromFilter results. Tallies using it were
previously missing scores; they will now be larger. Cell-to-cell partial
currents (a flux score with this filter) are unaffected. See below for why.

Problem

material_last was doing two unrelated jobs:

  1. the tally attribute read by MaterialFromFilter, and
  2. the cache key for the cross section lookup in event_calculate_xs().

Because a particle's energy changes at every collision, event_collide() forced
a cache miss by setting material_last() = C_NONE. From the second collision in
a cell onward the particle therefore carried material_last == C_NONE, which
matches no filter bin, so those scores silently vanished from volumetric
MaterialFromFilter tallies. Cell-to-cell partial currents (a flux score with
one of these filters) are scored at the crossing itself, where the previous
material was still correct, and were not affected.

cell_last is not reset on collision, so the equivalent CellFromFilter tally
counted those scores, and the two decompositions of the same score disagreed.

The effect is largest where it is least visible: a thick, scattering-dominated
cell loses nearly everything after the first collision.

MWE

import openmc
SHELL_THICKNESS = 10.0 # cm of iron; raise it to make the shortfall worse
water = openmc.Material()
water.set_density('g/cm3', 1.0)
water.add_nuclide('H1', 2.0)
water.add_nuclide('O16', 1.0)
iron = openmc.Material()
iron.set_density('g/cm3', 7.87)
iron.add_nuclide('Fe56', 1.0)
inner_surf = openmc.Sphere(r=5.0)
outer_surf = openmc.Sphere(r=5.0 + SHELL_THICKNESS, boundary_type='vacuum')
inner = openmc.Cell(fill=water, region=-inner_surf)
outer = openmc.Cell(fill=iron, region=+inner_surf & -outer_surf)
model = openmc.Model()
model.geometry = openmc.Geometry([inner, outer])
model.settings.run_mode = 'fixed source'
model.settings.batches = 10
model.settings.particles = 2000
model.settings.source = openmc.IndependentSource(
space=openmc.stats.Point((0.0, 0.0, 0.0)),
energy=openmc.stats.delta_function(2.0e6),
)
in_outer = openmc.CellFilter([outer])
total = openmc.Tally(name='total')
total.filters = [in_outer]
total.scores = ['total']
by_cell = openmc.Tally(name='by cell')
by_cell.filters = [in_outer, openmc.CellFromFilter([inner, outer])]
by_cell.scores = ['total']
by_material = openmc.Tally(name='by material')
by_material.filters = [in_outer, openmc.MaterialFromFilter([water, iron])]
by_material.scores = ['total']
model.tallies = openmc.Tallies([total, by_cell, by_material])
with openmc.StatePoint(model.run(output=False)) as sp:
t = sp.get_tally(name='total').mean.sum()
c = sp.get_tally(name='by cell').mean.sum()
m = sp.get_tally(name='by material').mean.sum()
print(f"\n reaction rate in outer cell {t:12.6f}")
print(f" summed over CellFromFilter {c:12.6f}"
f" ({100 * c / t:6.2f}% of total)")
print(f" summed over MaterialFromFilter {m:12.6f}"
f" ({100 * m / t:6.2f}% of total)")
print("\n Both decompositions should equal the total exactly.")
if abs(m - t) > 1e-9 * t:
print(f" MaterialFromFilter is short by {100 * (1 - m / t):.1f}%.")
else:
print(" Both match.")

Changes

  • material_last is renamed to material_xs_cache, which is what it actually
    is. It is written in exactly the same places as before, so cross section
    caching behaviour is unchanged.
  • MaterialFromFilter now derives the material from cell_last at the lowest
    coordinate level instead of tracking it separately. The two "from" filters
    read one source of truth and cannot drift apart again.
  • Adds cell_instance_last, maintained on the same cadence as cell_last. A
    cell with distributed materials resolves a different material per instance, so
    the cell index alone does not determine which material a particle came from.
  • Documents the semantics of cell_last, cell_instance_last and
    material_xs_cache on their accessors, including that the first two are
    deliberately not updated on collision and that the last must never be used as
    a tally attribute.

This deliberately leaves event_calculate_xs() alone. Rerouting the cache key
was the alternative; deriving the material instead means there is no cross
section behaviour to re-verify.

Testing

tests/unit_tests/test_filter_material_from.py:

  • A water sphere inside an iron shell, one material per cell so the two
    decompositions are the same partition. Asserts both sum to the undecomposed
    total and agree bin for bin. The material sum falls well short of the total
    before this change.
  • A 2x2 lattice of one repeated cell carrying four distributed materials,
    asserting all four "from" bins are populated. Resolving the fill without the
    instance would put the whole total in the first bin.
  • A cell-to-cell partial current (flux score), asserting the material and cell
    decompositions still agree. cell_last and cell_instance_last are saved at
    the top of event_cross_surface(), before the crossing, so the derived filter
    sees the same thing the tracked attribute used to.

Both are per-history identities within a single run, so they hold to round-off
rather than statistically.

tests/regression_tests/surface_tally uses MaterialFromFilter with a flux
score, which makes it a partial current rather than a volumetric tally, so its
stored results should be unchanged. A unit test covers that path so the change
is shown not to regress it.

Notes

material_last was stale at birth as well: a fresh particle inherited whatever
the reused Particle object last held, since find_cell() writes the
pre-existing material before overwriting it. Deriving from cell_last, which is
initialised to the particle's own cells at birth, removes that too.

This is the material_last portion of #3849, split out as suggested in review
there. It does not change cell_last semantics.

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)

@GuySten
GuySten marked this pull request as ready for review August 31, 2026 18:45
Sign up for freeto 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.

1 participant

@GuySten
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Fix MaterialFromFilter dropping scores after the first collision in a cell - #4090

Open
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix
Open

Fix MaterialFromFilter dropping scores after the first collision in a cell#4090
GuySten wants to merge 2 commits into
openmc-dev:developfrom
GuySten:material-filter-fix

Conversation

@GuySten

@GuyStenGuySten commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fix MaterialFromFilter dropping scores after the first collision in a cell

This changes volumetric MaterialFromFilter results. Tallies using it were
previously missing scores; they will now be larger. Cell-to-cell partial
currents (a flux score with this filter) are unaffected. See below for why.

Problem

material_last was doing two unrelated jobs:

  1. the tally attribute read by MaterialFromFilter, and
  2. the cache key for the cross section lookup in event_calculate_xs().

Because a particle's energy changes at every collision, event_collide() forced
a cache miss by setting material_last() = C_NONE. From the second collision in
a cell onward the particle therefore carried material_last == C_NONE, which
matches no filter bin, so those scores silently vanished from volumetric
MaterialFromFilter tallies. Cell-to-cell partial currents (a flux score with
one of these filters) are scored at the crossing itself, where the previous
material was still correct, and were not affected.

cell_last is not reset on collision, so the equivalent CellFromFilter tally
counted those scores, and the two decompositions of the same score disagreed.

The effect is largest where it is least visible: a thick, scattering-dominated
cell loses nearly everything after the first collision.

MWE

import openmc
SHELL_THICKNESS = 10.0 # cm of iron; raise it to make the shortfall worse
water = openmc.Material()
water.set_density('g/cm3', 1.0)
water.add_nuclide('H1', 2.0)
water.add_nuclide('O16', 1.0)
iron = openmc.Material()
iron.set_density('g/cm3', 7.87)
iron.add_nuclide('Fe56', 1.0)
inner_surf = openmc.Sphere(r=5.0)
outer_surf = openmc.Sphere(r=5.0 + SHELL_THICKNESS, boundary_type='vacuum')
inner = openmc.Cell(fill=water, region=-inner_surf)
outer = openmc.Cell(fill=iron, region=+inner_surf & -outer_surf)
model = openmc.Model()
model.geometry = openmc.Geometry([inner, outer])
model.settings.run_mode = 'fixed source'
model.settings.batches = 10
model.settings.particles = 2000
model.settings.source = openmc.IndependentSource(
space=openmc.stats.Point((0.0, 0.0, 0.0)),
energy=openmc.stats.delta_function(2.0e6),
)
in_outer = openmc.CellFilter([outer])
total = openmc.Tally(name='total')
total.filters = [in_outer]
total.scores = ['total']
by_cell = openmc.Tally(name='by cell')
by_cell.filters = [in_outer, openmc.CellFromFilter([inner, outer])]
by_cell.scores = ['total']
by_material = openmc.Tally(name='by material')
by_material.filters = [in_outer, openmc.MaterialFromFilter([water, iron])]
by_material.scores = ['total']
model.tallies = openmc.Tallies([total, by_cell, by_material])
with openmc.StatePoint(model.run(output=False)) as sp:
t = sp.get_tally(name='total').mean.sum()
c = sp.get_tally(name='by cell').mean.sum()
m = sp.get_tally(name='by material').mean.sum()
print(f"\n reaction rate in outer cell {t:12.6f}")
print(f" summed over CellFromFilter {c:12.6f}"
f" ({100 * c / t:6.2f}% of total)")
print(f" summed over MaterialFromFilter {m:12.6f}"
f" ({100 * m / t:6.2f}% of total)")
print("\n Both decompositions should equal the total exactly.")
if abs(m - t) > 1e-9 * t:
print(f" MaterialFromFilter is short by {100 * (1 - m / t):.1f}%.")
else:
print(" Both match.")

Changes

  • material_last is renamed to material_xs_cache, which is what it actually
    is. It is written in exactly the same places as before, so cross section
    caching behaviour is unchanged.
  • MaterialFromFilter now derives the material from cell_last at the lowest
    coordinate level instead of tracking it separately. The two "from" filters
    read one source of truth and cannot drift apart again.
  • Adds cell_instance_last, maintained on the same cadence as cell_last. A
    cell with distributed materials resolves a different material per instance, so
    the cell index alone does not determine which material a particle came from.
  • Documents the semantics of cell_last, cell_instance_last and
    material_xs_cache on their accessors, including that the first two are
    deliberately not updated on collision and that the last must never be used as
    a tally attribute.

This deliberately leaves event_calculate_xs() alone. Rerouting the cache key
was the alternative; deriving the material instead means there is no cross
section behaviour to re-verify.

Testing

tests/unit_tests/test_filter_material_from.py:

  • A water sphere inside an iron shell, one material per cell so the two
    decompositions are the same partition. Asserts both sum to the undecomposed
    total and agree bin for bin. The material sum falls well short of the total
    before this change.
  • A 2x2 lattice of one repeated cell carrying four distributed materials,
    asserting all four "from" bins are populated. Resolving the fill without the
    instance would put the whole total in the first bin.
  • A cell-to-cell partial current (flux score), asserting the material and cell
    decompositions still agree. cell_last and cell_instance_last are saved at
    the top of event_cross_surface(), before the crossing, so the derived filter
    sees the same thing the tracked attribute used to.

Both are per-history identities within a single run, so they hold to round-off
rather than statistically.

tests/regression_tests/surface_tally uses MaterialFromFilter with a flux
score, which makes it a partial current rather than a volumetric tally, so its
stored results should be unchanged. A unit test covers that path so the change
is shown not to regress it.

Notes

material_last was stale at birth as well: a fresh particle inherited whatever
the reused Particle object last held, since find_cell() writes the
pre-existing material before overwriting it. Deriving from cell_last, which is
initialised to the particle's own cells at birth, removes that too.

This is the material_last portion of #3849, split out as suggested in review
there. It does not change cell_last semantics.

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)

@GuySten
GuySten marked this pull request as ready for review August 31, 2026 18:45
Sign up for freeto 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.

1 participant

@GuySten