Skip to content

Naive Flux Linear Source Consistency Fix - #4111

Merged
paulromano merged 10 commits into
openmc-dev:developfrom
jtramm:ls_flux_consistency
Sep 11, 2026
Merged

Naive Flux Linear Source Consistency Fix#4111
paulromano merged 10 commits into
openmc-dev:developfrom
jtramm:ls_flux_consistency

Conversation

@jtramm

@jtramm jtramm commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR fixes a statistical inconsistency in the linear source flux update. Basically, the naive estimator currently uses per-batch "naive" volumes, but simulation-averaged centroids.

The naive volume treatment updates the flux from a single batch's rays, but the linear source is anchored to the simulation-averaged centroid, so the two halves of the update describe different sets of tracks. The difference (the source gradient dotted with the batch's centroid fluctuation) was silently absorbed by the flux estimate as noise, which breaks the exactness the naive treatment promises and can ignite negative fluxes in optically thin scatter-fed regions. About 15 lines of logic that affect linear source mode only.

Independent of the adaptive volume estimator PR (#4110), and reviewable in either order. That said, as I developed PR #4110 first and it also improves performance, I measure results against a "baseline" value generated by the PR #4110 branch, which is where the auto results below come from.

The fix

For source regions using the naive flux estimator, the flux update now adds back the source as the batch's tracks actually integrated it, evaluated at the batch centroid rather than the accumulated one. The term is applied only when a region updates with its own batch (naive) volume, which is exactly where it makes the update an exact per-batch track identity (the flux inherits the sign of the angular fluxes it averages). Regions updating with the simulation-averaged volume keep the original treatment, which already correctly matches the simulation-averaged volume with a simulation-averaged centroid.

Results

Stability on the shielding test problem (the "irradiation vault hall" problem from #4110, naive estimator, linear sources, 2,000 rays, 100 inactive and 100 active batches), negative slow-group bins of 85,750 and worst amplitude:

negative bins worst amplitude
before 5,724 −3.8e-1
after 72 −2.7e-3

Accuracy on the C5G7 overlay-mesh eigenvalue case (linear_xy, 200 rays, 102x102 mesh), where the naive estimator's linear-mode noise was previously severe:

k dev (pcm) AAPE
naive, before −5,514 4.09%
naive, after −3,524 1.58%
auto (default), before +45 0.65%
auto (default), after +45 0.65%

The high error on C5G7 is expected as the naive estimator is being used. For accurate results, the simulation averaged, hybrid, or adaptive estimators are far better. However, these results do demonstrate that the naive estimator becomes much more accurate, which is the goal, and does allow for hybrid/adaptive estimators to maintain accuracy in cases where more cells are demoted under low ray density conditions etc.

The auto rows were measured with this fix combined with the adaptive estimator PR: the default path is untouched at measurement precision, since under the adaptive family the term only fires in the rare batch where a transiently hit-starved region still carries its fitted gradients. Pinned naive and hybrid linear runs see the full correction.

Compatibility and testing

  • Flat source mode is bit-for-bit unaffected (the term is identically zero), and the simulation-averaged linear reference results are untouched byte for byte, confirming the original arithmetic survives where the term does not apply.
  • Reference results are regenerated for the linear-mode tests whose estimators receive the term (naive, and hybrid through its external-source and hit-starved regions). A new random_ray_linear_source_stability regression test pins the fix on a starved naive linear configuration.
  • The methods documentation gains one paragraph deriving the batch-sampled source and stating the pairing rule.

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)

John Tramm and others added 5 commits July 23, 2026 02:36
The transport sweep evaluates each region's linear source against the
accumulated centroid, but a batch's tracks average that source at their
own track-length-weighted centroid, which fluctuates about the
accumulated one as rays sample the region unevenly. The flux update
added back only the flat source, so the difference -- the source
gradient dotted with the per-batch centroid fluctuation -- was silently
absorbed by the flux estimate as zero-mean noise with no flat-source
counterpart. This broke, in linear source mode only, the defining
property of a naive-volume update: that it adds no noise of its own
beyond the angular fluxes it averages (its error being ratio bias
alone). In near-cancellation regions (scattering ratio near one), where
the reduced source approximately equals the flux, the absorbed noise
can exceed the flux itself at modest hit counts and ignite
self-sustaining negativity that should be structurally impossible.

The update now adds back the source as actually integrated by the
batch's tracks, weighted by the ratio of the batch volume to the volume
in use. Under the batch (naive) volume the weight is one and the update
becomes an exact per-batch track identity, so the flux inherits the
sign of the angular fluxes; under the simulation-averaged volume the
weight prevents the correlation between batch volume and batch centroid
(sampled by the same rays) from becoming a systematic gradient-aligned
bias in cut regions. Flat source solves are unaffected.

On a fixed-source shielding reproducer (near-void hall with scattering
ratio 0.99, naive estimator, 2,000 rays), the fix reduces negative flux
bins from 5,788 (amplitudes up to 70% of the peak flux) to 21 (0.05% of
peak). Linear source test golds are regenerated, and a new regression
test pins the previously unstable naive-plus-linear regime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restrict the batch-sampled gradient term to regions updating with their
own batch (naive) volume, where adding it back makes the update an
exact per-batch track identity that inherits the sign of the angular
fluxes it averages. Regions updating with the simulation-averaged
volume keep the original update form. The omitted term cannot bias
their accumulated results, since the accumulated centroid is built from
the same batch centroids and the deviations sum to exactly zero, and
the original form samples the residual between the angular flux and the
linear source model, which carries less noise than the direct form
wherever the model tracks the field. The estimator matrix agrees: the
term is transformative for naive-volume updates and only adds variance
under simulation-averaged ones (e.g., 0.5% average pin power error on
C5G7 with the hybrid estimator at a 3.7% miss rate).

The simulation-averaged reference results are untouched by this commit,
confirming the original arithmetic survives bit for bit. The adaptive
family shifts slightly: a transiently hit-starved region carries its
fitted gradients for one batch before the flat fallback catches up, and
now receives the consistent update in that batch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
John Tramm and others added 5 commits September 10, 2026 16:53
Brings in the adaptive volume estimators (openmc-dev#4110). The code merged
cleanly; the linear volume estimator test's reference results for the
hybrid, naive, adaptive and strict adaptive estimators are regenerated,
since develop's versions predate the batch-consistent flux update.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
The strict adaptive estimator rescues a negative iterate by rescaling
the transport part of the update to the batch volume, reconstructing the
naive-volume update from the additive term. With the batch-consistent
linear source update that term depends on the volume: a region updated
with its batch volume adds the gradient dotted with the batch centroid
offset. The additive term now takes the volume and the linear source
solver overrides it, so the flux update and the rescue draw the term
from one place and the rescued candidate is exactly the naive-volume
update in linear mode too. Only the strict adaptive linear reference
results move.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
The linear source solver decided whether an update used the batch
volume by comparing the volume it was handed with the region's batch
volume, a floating-point equality that also holds by coincidence on the
first batch for every estimator. The decision is already made at the
call site, so it is now passed as a flag through the flux update and
the additive term instead of being reconstructed. Results are unchanged.
The comment on the simulation-averaged path no longer claims the omitted
term sums to exactly zero: each batch's term is taken against the running
centroid with that batch's gradient, so it has no persistent sign and its
contribution shrinks with the batch count, which is what the pairing
relies on. A stale cross-reference is fixed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
The test's 12x12x12 overlay mesh coincided with the example's lattice
and subdivided nothing, and with 90 rays no region was ever missed, so
the "modest per-batch hit counts" its comment described did not exist.
It now runs 20 rays over the example's 1,728 lattice regions, missing
about 4% of them per batch, which is the regime the batch-consistent
update targets and where its effect is ten times larger. The reference
results are regenerated; the test runs in under 0.2 s at 2 threads,
holds at 1, 2 and 4 threads, and fails against develop's binary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
The note used a barred flat source, a new centroid symbol and no group
index where the surrounding linear source section writes the region
source as Q_{i,g}, the centroid as r_c and the flux update's additive
term as Q/Sigma_t. It now uses the section's symbols and equations, and
describes the omitted term on the simulation-averaged path as having no
persistent sign rather than summing to exactly zero.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
@jtramm

jtramm commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Merged develop, which now includes the adaptive volume estimators (#4110). One interaction with #4110 needed a fix. The strict adaptive estimator rescues a negative flux iterate by rebuilding the batch-volume update from an additive term. With this PR, that term depends on which volume the update used. The additive term now takes an explicit batch-volume flag, so the flux update and the rescue always agree.

I also made two other small changes: 1) the regression test now uses 20 rays and no overlay mesh, since the old mesh matched the lattice and no region was ever missed, and 2) the methods note now says the term omitted on the simulation-averaged path is zero-mean and shrinks with the batch count.

@paulromano paulromano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All looks good; thanks @jtramm!

@paulromano
paulromano merged commit 7ca0425 into openmc-dev:develop Sep 11, 2026
17 checks passed
jtramm pushed a commit to jtramm/openmc that referenced this pull request Sep 11, 2026
Brings in the naive-volume linear source consistency fix (openmc-dev#4111). The
source files merged cleanly. Both branches added a regression test named
random_ray_linear_source_stability, so the limiter's test is renamed to
random_ray_source_gradient_limiter and develop's is kept as is; the
limiter test's reference results are regenerated, since the consistency
term changes the naive-volume linear flux it pins. The methods
documentation keeps both new sections, the consistency note followed by
the limiter section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
GuySten pushed a commit to Suark94/openmc that referenced this pull request Sep 13, 2026
Resolve conflicts between the domain decomposition work and the linear
source gradient limiter (openmc-dev#4121) and naive flux consistency fix (openmc-dev#4111)
that landed on develop in the meantime.

Resolution notes:

* SourceRegion: develop's new scalar fields (n_negative_batches_,
  converged_negative_) move into ScalarSourceRegionFields alongside the
  rest, so they are carried by the existing raw-byte MPI transfer in
  DecompositionMap::send_sr_data(). The gradient limiter's extent_ stays
  on SourceRegion, since BoundingBox is not part of the scalar block.

* SourceRegionContainer gained three configuration flags on develop
  (is_adaptive, is_strict_adaptive, track_extents). Added empty_like() so
  DecompositionMap::redistribute_source_regions() can rebuild a container
  with the same configuration instead of hardcoding the old two-argument
  constructor.

* simulate(): kept the decomposed structure from this branch and folded in
  develop's changes -- apply_transport_stabilization() is now part of
  add_source_to_scalar_flux(), and demotion_step() runs before flux_swap().

* Applied develop's OpenMP portability fix (accumulate into a local rather
  than naming a class member in a reduction clause) to both transport_sweep()
  and transport_sweep_decomp().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NKmpLGykRSiPAXyCPqWQD
GuySten pushed a commit to Suark94/openmc that referenced this pull request Sep 13, 2026
redistribute_source_regions() rebuilds a rank's SourceRegionContainer by
round-tripping every region -- retained as well as migrating -- through
SourceRegion(const SourceRegionHandle&) and SourceRegionContainer::push_back().
That constructor set most of the region's scalars and arrays and silently
dropped everything
develop has added since, and push_back() hardcoded two more fields to zero:

  centroid_offset_        reset to {0,0,0}   (openmc-dev#4111)
  scalar_flux_t_          reset to 0.0       (openmc-dev#4110)
  converged_negative_     reset to 0         (openmc-dev#4110)
  n_negative_batches_     reset to 0         (openmc-dev#4110)
  extent_                 reset to inverted  (openmc-dev#4121)

This fired on every rank in every balanced batch, whether or not any region
actually moved: balance_load() falls through to redistribute_source_regions()
even when the imbalance is already inside tolerance.

The worst of the five is centroid_offset_. Within a single batch the order is

  normalize_scalar_flux_and_volumes()   writes centroid_offset
  balance_load()                        zeroes it
  add_source_to_scalar_flux()           reads it via flux_additive_term()

so for a linear-source decomposed run, batches 2..ITER_LOAD_BALANCE dropped
the q_gradient . delta_centroid term that openmc-dev#4111 added to stop gradient-scale
noise igniting self-sustaining negativity. scalar_flux_t_ and
converged_negative_ matter because AUTO resolves to an adaptive estimator by
default, so every default decomposed run discarded the first five batches of
the demotion accumulator and released a flag that source_region.h documents
as never being released.

Fixes: carry centroid_offset_ in ScalarSourceRegionFields (so the raw-byte
transfer picks it up for free), add scalar_flux_t_ to SourceRegion with its
own MPI message, wire both onto SourceRegionHandle, null-check the
feature-gated handle pointers in the constructor, and have push_back()
propagate rather than zero them.

This also completes the extent_ transfer added in "Do not sum external source
when merging contested source regions": that commit added the message, but the
migrating region was built by this same constructor, so what went over the
wire was always BoundingBox::inverted().

Added a comment on ScalarSourceRegionFields stating the invariant, since the
current layout makes this failure mode invisible and it will recur every time
develop adds a per-region field.

Two things the new reads in the handle constructor require:

SourceRegionHandle's raw pointers now all default to nullptr. extent_ is
assigned only inside the `if (handle.is_linear_)` branch of
get_source_region_handle(), so on the flat-source path -- the default -- it
was left indeterminate, and the `if (handle.extent_)` test below would
dereference garbage on the first load balancing pass of every decomposed
run. n_negative_batches_ and converged_negative_ are null-tested the same way
and were safe only because both current paths happen to assign them, so all
of them are defaulted rather than just the one that bit. Measured cost of
defaulting all 36: +48 bytes of text in source_region.cpp and -34 bytes in
flat_source_domain.cpp, where the hot-path handle construction lives -- the
compiler drops the redundant stores. The SourceRegion& constructor also stops
relying on data() of an unallocated vector being null, which libstdc++ does
but the standard does not promise.

SourceRegion's scalar_flux_t_ allocation is gated on the estimator actually
being adaptive. That constructor runs on the transport path once per newly
discovered region, so allocating unconditionally would add a heap allocation
per discovery and negroups * 8 bytes per live transient region to every run,
serial and non-adaptive included. The gate derives from the same
resolved_volume_estimator_ the container uses, which is fixed before any
SourceRegion is constructed within a solve, so the two cannot disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NKmpLGykRSiPAXyCPqWQD
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants