Naive Flux Linear Source Consistency Fix - #4111
Conversation
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
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
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
|
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
left a comment
There was a problem hiding this comment.
All looks good; thanks @jtramm!
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
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
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
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
autoresults 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:
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:
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
autorows 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. Pinnednaiveandhybridlinear runs see the full correction.Compatibility and testing
random_ray_linear_source_stabilityregression test pins the fix on a starved naive linear configuration.Checklist