Skip to content

Adaptive Volume Estimators - #4110

Merged
paulromano merged 36 commits into
openmc-dev:developfrom
jtramm:rr_adaptive_simple
Sep 9, 2026
Merged

Adaptive Volume Estimators#4110
paulromano merged 36 commits into
openmc-dev:developfrom
jtramm:rr_adaptive_simple

Conversation

@jtramm

@jtramm jtramm commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Overview

In the random ray solver there are a variety of ways to numerically treat the volume term that shows up in the MOC equations. You can read more background info in the "Volume Dilemma" section of the theory docs.

The crux of the issue is that the most natural treatment (known as the "naive" volume estimator) generally guarantees positivity and stability and has a low variance, though at the expense of being a biased ratio estimator. As such, eigenvalue solves using this naive setting might experience hundreds or even thousands of pcm error in k-eff, which is unacceptably high. Early in the random ray development the "simulation averaged" estimator was developed that accumulates volume from rays run over the course of the entire simulation, rather than only the last batch as in the naive approach. The simulation averaged estimator is thus unbiased (as it is effectively no longer a ratio estimator) but comes at the cost of 1) higher variance, and 2) potential for instability and negative fluxes under certain conditions.

Subsequent work introduced the "hybrid" estimator (merged in #3060) that took a "best of both worlds" approach. Generally the stability/negativity problems with the simulation averaged estimator are worst when there is an external source present in a region with a very low Sigma_t. Thus, the hybrid estimator tries to use the unbiased simulation averaged estimator everywhere it can, but then demoted regions that had an external source present.

To date the hybrid estimator has been the default and works well in most cases. However, in working on the JET problem, it became apparent that some fixed source problems were still becoming unstable and producing significant numbers of source regions with negative (or extremely large) fluxes. As such, JET simulations needed to be performed with the naive estimator. Given that the naive estimator is biased, I wondered if we might be able to make more efficient weight windows if we were able to come up with a better volume estimator that allowed more cells to use the simulation averaged estimator while ensuring stability.

This PR makes more progress to a "best of both worlds" approach between the naive and simulation averaged estimators by introducing more intelligent criteria for when to demote regions to naive that strike at the root issues. It adds two new volume estimators to the random ray solver (adaptive and strict_adaptive), and also introduces an auto setting that allows for OpenMC to make an intelligent choice about which estimator to use given the type of problem being simulated.

As a result of this PR, the user can now simply select "auto" for this setting (or leave it at default), and shouldn't need to think about it anymore:

settings.random_ray['volume_estimator'] = 'auto'

The Adaptive Estimator

adaptive uses simulation_averaged by default and "demotes" individual source regions to use the naive estimator where simulation_averaged is unsafe. Regions are selected in three ways:

  1. Hit-starved regions (averaging fewer than 1.5 ray crossings per batch) receive the naive treatment in every batch, as under hybrid.
  2. While the source is converging (the inactive batches), a per-batch guard applies the naive treatment to any region whose ratio of reduced source to flux estimate exceeds a fixed threshold (4×), or whose reduced source is negative while its flux is not (possible only with transport-corrected cross sections). This guard is what keeps the iteration finite on problems where simulation_averaged diverges.
  3. From each region's running accumulated flux, first at the end of the inactive batches and then re-evaluated every active batch as the accumulation grows, two demote-only decisions are made. Regions whose accumulated flux is negative are demoted to the naive treatment (in the active phase this also watches the active-only accumulation that tally means are computed from), as are regions whose reduced source is dominated by contributions that do not derive from their own flux (scattering from other groups, fission, or an external source) in excess of 4x their accumulated flux. Once demoted, a region is never returned.

The division of labor matters. Per-batch tests act on noisy single-batch values, so they are confined to the inactive phase, where they serve only stability. In the active batches, the ones tallies accumulate, the treatment of every region changes only by demote-only decisions made from accumulated estimates, so the choice never churns with the sign or size of single-batch noise. Continuing the accumulated decisions through the active phase also matters at scale. On problems too large to afford an inactive phase that converges every deep region (verified on a 105M-region shielding model), the transition-time decisions alone miss regions whose instability only surfaces after tallies begin, and a single unprotected region can corrupt the solution far beyond its own boundary through scattering feedback.

The Strict Adaptive Estimator

adaptive selects estimators but never modifies a computed flux value. That is what keeps it unbiased, yet in some cases very slightly negative fluxes can still occur. Adaptive is configured to ensure simulation averaged is used maximally while preserving stability and still ensuring minimal bias from use of the naive estimator. This ensures good results on almost all problems I tested. However, on weight window generation problems that computed an adjoint source based on the forward flux, I did notice some problems popping up still. On the 105M source region JET model, a 0.67% negative fraction in the forward flux grew to ~3% in the adjoint and produced visibly noisy weight windows. This is a major improvement over 'hybrid', but is worse in practice than windows generated from the crude naive estimator. As my goal for this project was to improve performance over naive, this was quite disappointing. Thus, I developed the "strict adaptive estimator"

Fixing the remaining rare negative source regions via more demotion cannot close this gap. Negativity is contagious (a region on a safe estimator can still inherit it through in-scatter from neighbors that are not), so no selection rule alone controls the sign. strict_adaptive therefore runs the adaptive machinery unchanged and adds a per-batch fixup applied to the flux values themselves:

  1. Rescue. A region whose batch flux comes out negative is first recomputed with that batch's own volume (algebraically, the naive update), whose term-by-term consistency removes the volume-mismatch noise responsible for most negative excursions.
  2. Floor. If the flux is still negative, it is replaced with the previous iterate, keeping the flat flux moments non-negative batch over batch. (In every test in this PR, including the 105M-region model below, the resulting solutions contain no negative fluxes. With a linear source shape the reconstructed in-region flux can still locally dip below zero, so this is enforced behavior on the flux iterates rather than a theoretical guarantee on all outputs.)
  3. Chronic demotion. A region that keeps going negative is demoted outright to the naive treatment. This channel is essential for bias rather than coverage. The floor prevents the accumulated flux from ever showing the negative sign that adaptive's own demotion watches for, so without an exit a chronically noisy region would be clipped upward every batch, biasing its flux. Demotion instead moves such regions onto an estimator that needs no clipping.

The auto default

The default volume_estimator is now auto, resolved once at the start of the run. Solves whose results feed variance reduction (weight window generation, and any adjoint workflow, including the forward solve an adjoint source is derived from, since that is where the negativity originates) receive strict_adaptive, and everything else receives adaptive. The report prints the resolution (e.g. Volume Estimator Type = Strict Adaptive (auto)), and setting any concrete estimator disables the routing.

Validation

Five problems, each comparing several estimators against a converged multigroup Monte Carlo or published reference. Every table reports the count of negative flux tally bins. The "miss rate" is the average fraction of source regions not crossed by any ray in a batch. OpenMC recommends staying below 1%, and several tests deliberately run far above it as a stress test. (A streaming-dominated problem, a one-group point source in a near-void room where simulation_averaged's failure is driven by ray-count variance rather than by the source-to-flux ratio, was also checked. adaptive matches hybrid to within 0.25% on all region tallies there while leaving 2 negative tally bins to hybrid's 26, and simulation_averaged fails catastrophically.)

1. C5G7 eigenvalue benchmark: no change where the default was already good

Pin-resolved 2D C5G7 (143k source regions), scored against the published eigenvalue (1.18655) and pin powers (AAPE = average absolute pin power error, MAX = worst pin):

miss rate estimator k dev (pcm) AAPE MAX pin neg bins
1.0% naive −3,118 1.58% 7.32% 0
1.0% simulation_averaged +40 0.61% 2.14% 0
1.0% hybrid +40 0.61% 2.14% 0
1.0% adaptive +30 0.37% 1.66% 0
1.0% strict_adaptive +365 0.79% 4.36% 0
10.2% naive −5,662 1.29% 6.33% 0
10.2% simulation_averaged +61 0.67% 2.96% 0
10.2% hybrid +61 0.67% 2.96% 0
10.2% adaptive −1 0.58% 2.80% 0
10.2% strict_adaptive −671 4.65% 11.03% 0
20.4% naive −6,451 1.68% 7.53% 0
20.4% simulation_averaged +92 0.67% 3.04% 0
20.4% hybrid −79 2.15% 11.47% 0
20.4% adaptive −20 1.03% 6.95% 0
20.4% strict_adaptive −2,924 4.91% 10.30% 0

adaptive matches the accurate estimators from well-resolved conditions through heavy starvation, while naive shows the bias that motivates not simply using it everywhere. The strict_adaptive rows show the cost of its repair machinery growing with starvation, which is part of why it is not used for standard solves.

2. Shielding problem with scatter-fed regions: the failure the default has

The "irradiation vault" problem consists of a 12 cm cavity emitting 2 MeV neutrons at one side of a large near-void air hall (Σt ≈ 3×10⁻³ cm⁻¹ fast, 3×10⁻⁵ cm⁻¹ slow), several absorber blocks, and a thick concrete shell, discretized on a 1.3 cm source-region mesh (1.24M regions). The hall's slow group is fed almost entirely by down-scatter from the fast group, so its reduced source exceeds its flux by orders of magnitude with no external source anywhere, which is exactly the configuration hybrid's trigger cannot see. Slow-group fluxes at 12,800 rays (2.8% miss) vs multigroup Monte Carlo:

estimator total cavity hall shield neg bins (of 85,750)
naive +486% ± 0.3% −0.1% +508% +469% 0
simulation_averaged crashes (flux not finite)
hybrid −22% ± 32% −0.4% −37% +117% 5,468
adaptive −0.5% ± 0.1% −0.3% −0.6% +0.1% 0
strict_adaptive 0.0% ± 0.1% −0.2% −0.2% +0.9% 0
vault_geometry_flux_z0

Geometry and converged adaptive-estimator flux (z = 0 slice, log scale). The fast group beams from the source cavity and is shadowed by the absorber blocks, while the slow group is the scatter-fed distribution across the hall.

3. Published stress test: the three-region cube

The cube problem from here, as built by openmc.examples.random_ray_three_region_cube with the source region at the severe end of the paper's cross-section sweep, discretized on a 0.6 cm source-region mesh (216k regions). Region-integrated fluxes vs multigroup Monte Carlo at two ray densities:

miss rate estimator source void absorber neg bins
18.4% naive −1.13% +1.26% −8.67% 0
18.4% simulation_averaged crashes (flux not finite)
18.4% hybrid −1.12% −0.89% −2.71% 0
18.4% adaptive −1.09% +1.03% +2.88% 0
18.4% strict_adaptive −1.01% +2.36% +2.78% 0
1.7% naive −0.94% +0.48% +0.93% 0
1.7% simulation_averaged crashes (flux not finite)
1.7% hybrid −0.95% −0.41% −0.20% 0
1.7% adaptive −0.96% −0.29% −0.04% 0
1.7% strict_adaptive −0.94% +0.55% +1.46% 0

4. Variations of the hall

This test isolates the case that motivated the strict adaptive estimator. It uses small variants of the irradiation vault from problem 2 (35^3 cells rather than 1.24M) run on a short schedule of 100 inactive and 100 active batches, which reproduces at small scale the conditions under which adaptive leaves a few negative bins on large problems. Two versions of the hall are used. The thin hall keeps the near-void air of the original problem, while the collisional variant raises the slow-group cross section of the hall gas by two orders of magnitude (keeping the scattering ratio near one), so the slow flux in the hall is built by many small collisions rather than by streaming. The collisional variant is also run at a quarter of the ray density (25% miss vs 4.3%) as a stress case. Slow-group region errors vs multigroup Monte Carlo, with negative bins counted over the 85,750 mesh tally bins:

configuration estimator cavity hall shield neg bins
thin hall (Σt = 3×10⁻⁵), 2000 rays naive −4.9% +63% +874% 0
adaptive −4.9% −5.3% −2.5% 0
strict_adaptive −4.8% −4.8% −1.8% 0
collisional hall (Σt = 3×10⁻³), 2000 rays naive −4.9% −4.4% +7.5% 0
adaptive −4.9% −5.3% −2.6% 81
strict_adaptive −4.8% −4.7% −1.8% 0
collisional hall, 500 rays naive −3.1% +134% +66% 0
adaptive −5.9% −4.6% +3.0% 531
strict_adaptive −5.4% +1.7% +6.8% 0

strict_adaptive matches adaptive's accuracy where both are stable, produces zero negative fluxes where adaptive leaves dozens to hundreds, and degrades gracefully where naive blows up. The cost of the one-sided fixup is a conservative bias. On the 1%-miss C5G7 benchmark above, k rises to +365 pcm (vs +30 pcm for adaptive) and pin-power AAPE to 0.79% (vs 0.37%), which is why it is not used as the standard default. For weight window generation, there is an extreme preference for guarantees on positivity

5. JET

This was the original test case that motivated this project. The hybrid estimator resulted in instability. The naive estimator worked but I had wondered if we were leaving WW efficiency on the table. I compared the two estimators on a full CADIS test of the JET model, where weight windows are generated with the given estimator and then an MC solve is run. The maximum relative standard deviation across the 20 TLD tallies is then reported in each run. The baseline naive run resulted in a maximum rel std dev of 4.54%. The new default auto setting (which routes to the strict_adaptive estimator when WW's are being generated) resulted in a max rel std dev of 3.24%, both cases using essentially the same exact runtime. This PR is therefore responsible for a nearly 2x improvement in overall MC FOM for the JET test case

Compatibility and testing

  • The default volume_estimator changes from hybrid to auto; hybrid, naive, and simulation_averaged are unchanged and remain selectable, and any explicit selection disables the automatic routing.
  • Existing random-ray regression tests that relied on the implicit default are pinned to hybrid, so their reference results are unchanged. The adaptive and strict_adaptive estimators are covered by the volume-estimator regression tests (flat and linear) and by two deliberately ray-starved test variants that are sensitive to the demotion decisions. A new three-case test pins the auto routing itself, with forward, adjoint, and weight-window-generator runs that set no estimator, so a regression in either routing trigger shifts a reference result.
  • At the default verbosity the end-of-run report adds a single line, the number of regions receiving the naive treatment in the final batch, with a per-cause breakdown (including the strict rescue/floor/chronic counts) available at verbosity 8. This may be helpful for understanding any problems that may arise with the new estimators in the future.
  • The methods and user's guide documentation describe both estimators, the enforcement mechanics, and the auto default.

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 29 commits July 9, 2026 21:18
The adaptive estimator generalizes hybrid: it uses the simulation-averaged
volume by default and demotes individual source regions to the naive
(iteration) volume plus a previous-iteration miss treatment wherever the
simulation-averaged estimator is at risk. Demotion is triggered by (1) a
strong inhomogeneous source -- any group whose reduced source q/Sigma_t is
negative or exceeds ADAPTIVE_VOLUME_KAPPA times the previous iteration's
scalar flux, the condition under which the flux update is a
near-cancellation that requires volume-consistent terms (this subsumes
hybrid's external-source heuristic and also catches optically thin
in-scatter-fed regions that hybrid misses); (2) hit starvation (the
existing small-region criterion); and (3) a converged-negative flux: the
unmodified simulation-averaged estimator runs through the inactive phase
while each region's flux is accumulated, and any region whose accumulated
flux is negative at the end of the inactive phase is demoted for all
active batches. Deciding on the sign of the accumulated estimate rather
than reacting to per-iteration fluctuations avoids clipping the lower tail
of the noise distribution, so merely-noisy regions keep the unbiased
estimator. Under linear sources, strong-source regions also fall back to a
flat source representation, since their gradient terms carry per-iteration
noise at the q/Sigma_t scale that the volume choice cannot cancel; the
general gradient (tilt) limiter remains a separate follow-up.

The adaptive estimator becomes the default (previously hybrid), both at
static initialization and in openmc_finalize_random_ray(), which restores
built-in defaults between in-process runs. The eigenvalue fw-adjoint
double solve now clears the accumulated forward flux before the adjoint
solve (in fixed source mode set_fw_adjoint_sources already consumes and
zeroes it), so the adjoint solve's demotion decision operates on clean
adjoint statistics. An end-of-run report breaks down the naive-volume
treatment by cause.

naive/simulation_averaged/hybrid behavior is unchanged (the estimator
application is refactored into two per-region policy decisions that
reproduce the legacy estimators exactly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Methods-guide derivation and rationale for the strong-source (kappa) test
and the end-of-inactive converged-negative demotion, a pros/cons and
recommendations table in the user's guide, and the volume_estimator entry
in the settings specification, all reflecting adaptive as the default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New reference cases: adaptive on the three-region cube (flat and linear
source shapes); a deliberately ray-starved adaptive case (~20% miss rate)
that exercises every mechanism at once (strong-source demotion,
hit-starved demotion, end-of-inactive converged-negative demotion, and
the previous-flux miss treatment); the transport-corrected (P0) pin cell
under adaptive, whose negative within-group scattering exercises the
negative-reduced-source treatment; a ray-starved fixed source adjoint
case whose second (adjoint) solve makes real demotion decisions; and a
ray-starved subdivided eigenvalue case engaging the demotion machinery in
eigenvalue mode. A unit test runs the same model twice through openmc.lib
in one process and asserts the reported estimator both times, guarding
the default restored by openmc_finalize_random_ray().

The existing random ray regression tests that relied on the implicit
default are pinned to hybrid so their reference results are byte-for-byte
unchanged (only test.py and the recorded inputs change), also avoiding
churn in the planned follow-up that changes the adaptive fallback
estimator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… sources

Previously only strong-source (kappa) demotions zeroed their source
gradients under linear source shapes; converged-negative demotions kept
live tilts fitted from the statistics of regions whose accumulated flux is
negative -- shape information with no meaning that only injects noise into
exactly the cells already prone to negativity. Demotion is now uniform in
effect: any demoted region uses the naive volume, the previous-flux miss
treatment, and a flat source. On a coarse-ray streaming problem this
removes a third of the negative flux tally bins (9,664 -> 6,429 of 42,875)
with the total flux unchanged well within statistics. The end-of-run
report line becomes 'Demoted -> Flat (linear)' covering the full
flattened set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documentation and code comments now describe present behavior only, with
change rationale left to commit messages: the methods guide states the
default once (with the adaptive estimator) instead of narrating what it
replaced, and wording that referenced mechanisms which do not exist in the
code base ('per-iteration rescue', 'positivity floor', 'no longer
enforced') is replaced with statements of what the solver actually does.
The converged-negative demotion flag is renamed from n_negative_fluxes
(a counter name on what is a 0/1 flag) to converged_negative, matching
the report and documentation terminology.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The volume-estimator parametrization axis enumerates estimators;
'adaptive_starved' is a scenario (the adaptive estimator at a deliberately
starved ray density), so it moves to a dedicated test function. Reference
results are unchanged (same configuration and working directory).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
At the previous ray density the source region miss rate was zero, so the
miss-treatment code paths -- where the volume estimators meaningfully
differ -- never executed in these tests. All four estimators now run
deliberately ray-starved (~20% miss rate), which exercises the
per-estimator volume choices, the miss treatments, and all of the
adaptive demotion mechanisms (strong-source, hit-starved, and
converged-negative) in every parametrization; the separate starved
adaptive case is absorbed into the adaptive parametrization. Reference
results are regenerated for the new configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ature

The strong-source test demoted any region whose reduced source was
negative in some group. In one-group problems the source sign is locked
to the previous flux sign (q = c*phi + q_external), so that branch
degenerated into a per-iteration negative-flux reactor: noisy streaming
regions flip-flopped between the simulation-averaged and naive policies
based on the sign of the previous iterate, conditioning the estimator
choice on the noise -- exactly the fluctuation-reactive behavior the
end-of-inactive converged-negative demotion is designed to avoid.

On the cube stress problem of Cosgrove and Tramm (Negative fluxes and
cell-miss errors in the random ray method, Prog. Nucl. Energy 192 (2026)
106153), built by openmc.examples.random_ray_three_region_cube, at a
20.5% miss rate this misclassified 29% of all regions as strong-source
and biased the adaptive estimator's void-region integral flux by -19%
relative to the Monte Carlo reference (hybrid: +0.1%). A negative
reduced source now counts as strong only when the region's own previous
flux is non-negative -- the genuine transport-corrected (TCP0) signature,
where negative within-group scattering drives the source negative
independently of the flux. With the fix the cube problem gives adaptive
a void error of -0.04% (strong-source count 8, exactly the external
source region), matching hybrid in all three regions, while sign-locked
chronic negativity is handled by the converged-negative demotion (6 -> 24
regions). Adaptive reference results are regenerated; the other
estimators are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-iteration strong-source test, evaluated on noisy
single-iteration values, has one blind state: an unlucky iteration can
drag a strongly fed region's source and flux negative together, and in
that state neither the ratio condition nor the negative-source condition
can fire, so the region rides the excursion on unprotected
simulation-averaged updates. Because the per-iteration noise scale in
such regions is set by q/Sigma_t rather than by the flux, their flux
averaged over a whole phase of batches can straddle zero: on the
irradiation-vault validation problem (in-scatter-fed thin hall, 5.9%
miss), 82 of 42,875 slow-group tally bins finish negative, each within
1.5 sigma of zero, on a different set of bins for every seed. No
sign-based demotion can remove this population -- the transition sign
test already catches its fair share (92 regions), and the survivors
simply re-roll in the active window.

The latch keys on the stable property the whole class shares: at the
inactive->active transition, alongside the existing converged-negative
decision and from the same accumulated flux, any region whose
flux-independent feed (cross-group in-scatter, fission, and external
source) exceeds ADAPTIVE_VOLUME_KAPPA times its own accumulated flux is
demoted for the active phase. A region with no cross-group or external
feed can never latch, so the estimator choice still never reacts to
sign-locked noise (the Cosgrove-cube constraint), and the external
source term is only read in fixed source mode, where the arrays exist.

Validation: the vault's negative bins go from 82/61 (two seeds) to 0/0
with the formerly negative bin set now scored to -2.7%/-1.8% (was -203%)
and the global answer unchanged; the Cosgrove cube is identical to the
digit; the latch is inert on TLD-class door-2 problems (no feed) and on
C5G7 (0 regions latched, k/pin powers identical to the digit).

Also reports the transition decisions on their own lines, since the
existing by-cause block is a final-iteration snapshot whose priority
attribution files transition-demoted regions under Strong Source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ray-starved adaptive tests exist as sensitive detectors for changes
to the adaptive estimator's demotion policy, and the latch fires in
both at their starved densities (a noisy 20-batch accumulated flux trips
the feed test in the adjoint fixed-source problem via the external term
and in the starved eigenvalue lattice via the fission feed). The other
thirteen tests in the adaptive-covered set -- including every
volume-estimator parametrization and the pinned-hybrid suite -- pass
against their existing golds byte-for-byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user guide should describe what the mechanism does, not cite the
PR's validation suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The end-of-run block had grown to nine diagnostic lines whose labels
(strong-feed latch, transition demotions) mean little without developer
context. At the default verbosity the report now prints one line --
'Number of Naive Demotions', the count of source regions receiving the
naive volume treatment in the final batch for any reason (the one-shot
transition demotions plus that batch's per-iteration demotions) -- which
is the barometer a user actually needs. The per-cause breakdown and the
transition-decision counts move behind verbosity 8, a previously unused
level that sits above the default (7) and below the per-particle output
(9), and the verbosity table in the settings documentation gains the
level-8 entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The verbosity-8 view had two blocks describing the same regions with
colliding vocabulary: 'Converged Negative (demoted)' was a residual
attribution bucket while 'Converged Negative (sign)' was a decision
count, and 'Strong-Feed Latch' is the same physical condition as
'Strong Source', just decided once from converged data instead of per
batch. Replace both blocks with a single partition of the demotion
total into four mutually exclusive causes named on a consistent
cause x when-decided axis:

   Strong source (end of inactive)
   Strong source (per batch)
   Negative flux (end of inactive)
   Hit-starved (per batch)

The transition decisions are counted with first priority in the
final-batch snapshot (their flags are fixed for the whole active phase,
so those counts equal the decisions made at the transition), which
removes the need for the separate transition-count bookkeeping and its
report block entirely, and makes visible at a glance how few regions
the per-batch test catches beyond the latched population.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Under a linear source shape every demoted region already runs with a
flat representation -- hit-starved regions through the mainline
small-region moment zeroing, the other causes through the adaptive
gradient fallback -- so the true linear-to-flat count is simply the
demotion total, and the printed line (total minus hit-starved) was both
derivable from the other lines and not that count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comments describe the code that exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a healthy overlay-mesh C5G7 eigenvalue (102x102 regions, 200 rays,
0.16% miss), the per-batch ratio test was demoting a churning ~1.4-2%
of regions every active batch -- none of them strong in equilibrium
(the strong-feed latch fires on zero regions) -- and that
noise-conditioned, one-sided treatment biased tallied batches: k ran
+18/+19/+18 pcm above simulation_averaged across three paired seeds
with a flat source, and +106/+114/+83 pcm above hybrid with linear_xy
(where demotion also flattens gradients, holding the solution at the
flat-source answer). Two alternatives measured worse or partial: the
tilt limiter recovers only ~40 pcm of the linear bias, and removing
just the gradient fallback while keeping the volume demotion is worse
than baseline.

The ratio condition now applies only while the source is converging
(the inactive batches), where it is the stability guard that keeps
door-1 problems finite. In the active batches the estimator choice is
governed by the stable classifications alone -- the strong-feed latch,
the converged-negative sign demotion, hit-starved regions, and the
per-batch negative-source (TCP0) condition -- so tallied batches never
see noise-conditioned demotion.

With the change the C5G7 overlay-mesh case gives k within -0/-1/-0 pcm
of simulation_averaged (flat, paired seeds) and +11/+18/+8 pcm of
hybrid (linear_xy), with pin-power AAPE matching to 0.006%; the
irradiation vault (0 negative bins both seeds, totals to the digit),
the Cosgrove cube (to the digit), and the TLD room (to the digit) are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ray-starved detector tests have active-phase per-batch ratio
demotions on the previous code, so their results shift; the other
thirteen adaptive-covered tests pass against their existing golds
byte-for-byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…phase

At scale, an inactive phase long enough to converge deep regions may be
unaffordable, and the transition-time decisions alone then miss chronically
unstable regions whose escapees corrupt the active phase (observed on a
105M-region shielding model as mass negative fluxes). The two accumulated-
flux demotions now re-evaluate every active batch, demote-only, from a new
running flux accumulator (kept separate from the active-only tally
accumulator, which the sign test also watches so a positive inactive sum
cannot mask a negative reported mean). Transition-batch decisions are
unchanged; the five adaptive golds shift by a few pcm from the added
active-phase demotions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Weight window generation and adjoint workflows require strictly
non-negative fluxes: the adjoint source is built from the forward flux,
so even a small population of noise-driven negative tally bins (which
the adaptive estimator permits by design in near-zero-flux regions, and
which grows through the adjoint solve) contaminates the generated
windows. No demotion criterion can close this gap by itself -- a region
can inherit negativity through in-scatter from neighbors that have not
yet been demoted -- so a value-level treatment is required.

The strict adaptive estimator runs the adaptive machinery unchanged and
adds a per-batch non-negativity enforcement: a negative batch flux is
first rescued (its transport term algebraically rescaled to the batch
volume, reproducing the naive-volume update) and floored at the
previous iterate if still negative, which an induction from the
non-negative initial condition turns into a guarantee. Regions whose
flux goes negative chronically are demoted outright: the floor masks
the accumulated-flux sign signal the adaptive demotion relies on, and
without the chronic channel such regions would be clipped every batch,
a one-sided ratchet that biases them upward. On shielding benchmarks
the strict estimator matches the adaptive estimator's accuracy with
zero negative fluxes where adaptive leaves a small negative residue at
short active batch counts, and it degrades far more gracefully than the
naive estimator at coarse ray densities (region errors of a few percent
where naive exceeds one hundred percent). The cost is a small
conservative bias (several hundred pcm on eigenvalue problems).

The volume estimator now defaults to "auto", which resolves by solve
type at the start of the run: strict adaptive for solves whose results
feed variance reduction (weight window generation, and any adjoint
workflow, including the forward solve an adjoint source is derived
from), and adaptive for all other solves. The resolution is reported in
the simulation output, and explicit estimator selections override it.
New regression tests pin the strict estimator in flat and linear source
modes and the auto routing itself in both solve types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The auto regression test's forward and adjoint cases pin the
adjoint-flag trigger of the automatic volume estimator selection, but
not its second trigger: a weight window generator present in settings
routes even a purely forward run to the strict adaptive estimator. Add
a weight_windows case that attaches an FW-CADIS generator without
setting an estimator, so a regression in the generator-presence trigger
shifts the gold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unit test guarding the finalize-time restoration of the default
volume estimator still asserted the pre-auto report string, so it fails
on the current branch (the report now reads "Adaptive (auto)"). Beyond
the string, the auto default changes what the test must do to detect a
leak: auto is resolved by overwriting the stored setting at the start
of each run, and a forward rerun re-resolves to the same estimator
whether or not finalize restored the default, so running the same
forward model twice can no longer distinguish a reset from stale
state. Run an adjoint solve first (which resolves the setting to the
strict adaptive estimator) and a forward solve second: if finalize
fails to restore "auto", the forward run inherits the strict
estimator and the reported type exposes it. This is also the sequence
real openmc.lib workflows use (an adjoint weight-window generation run
followed by forward runs in the same process).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The auto default was resolved by overwriting the configured
volume_estimator_ static with the concrete estimator at the start of
the run, which made the solver a second writer of a user setting: if
openmc_finalize_random_ray() failed to restore the default, a later
in-process run inherited the previous run's resolved estimator (an
adjoint weight-window generation run would leak the strict adaptive
estimator into subsequent forward runs of the same process).

Resolve into a separate resolved_volume_estimator_ instead, assigned
unconditionally at the start of every random ray solve, and point all
solver code at it. The configured setting is now never modified by the
solver, so the resolved value cannot go stale by construction, and the
"(auto)" report suffix follows directly from the configured value,
replacing the volume_estimator_is_auto_ flag. The finalize-time restore
of the configured setting remains, as for any settings static (XML
parsing only assigns it when the element is present), and the
persistence unit test now pins that surviving hazard: an explicit
estimator run followed by a default adjoint run in one process, which
also exercises the auto routing under openmc.lib. No behavior changes;
all reference results are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compress the adaptive and strict adaptive documentation to match the
register of the surrounding text: the user's guide estimator table rows
are cut to glanceable descriptions in line with the existing rows, the
default-selection paragraphs and the methods discussion are shortened
to their load-bearing content, and the settings references state the
options and the automatic selection without re-explaining the
estimators. Also drop the claims that the strict adaptive estimator
guarantees non-negative fluxes -- it enforces a per-batch fixup on the
flat flux iterates, which is not a theoretical guarantee on all outputs
(under a linear source shape the reconstructed in-region flux can still
locally dip below zero) -- and confine the discussion of negative
fluxes to the mechanism explanations, framing them as arising in
pathological cases rather than as a routine occurrence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
The simulation averaged estimator is unbiased, not merely low-bias.
Correct the description in the new default-selection paragraph, and the
two pre-existing instances in the hybrid estimator's table row and
methods discussion that made the same claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
Restructure the added comments, docstrings, and documentation prose to
read like the surrounding text, replacing dash asides, semicolon joins,
and colon-led definitions with plain sentences. No wording changes to
upstream text and no changes to code or reference results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
Rewrite the methods discussion of the adaptive and strict adaptive
volume estimators from scratch. The purpose of each estimator is now
stated up front, the demotion conditions are given as short separate
sentences instead of one long enumeration, and the mechanism detail
that the section does not need is left to the code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
Place a short entry for the auto default first in the user's guide
estimator comparison table, so readers see immediately that OpenMC
selects an appropriate estimator on its own and that the rest of the
table is only relevant when overriding it. The selection rule itself is
described in the paragraphs that follow the table.

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
@jtramm
jtramm requested a review from paulromano September 3, 2026 02:29
John Tramm and others added 2 commits September 3, 2026 02:35
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
The weightwindows_fw_cadis_mesh test was the one FW-CADIS test that
set no volume estimator, so it silently rode the default. Under the
new auto default it resolves to the strict adaptive estimator and its
reference results no longer match, which failed CI. Pin it to hybrid,
the previous default, restoring its reference results byte for byte
(only the inputs gain the explicit element). The auto behavior of
weight window generation is covered by the volume estimator auto test.

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

@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.

Thanks @jtramm! Doing a review in full right now but here is some immediate feedback from GPT-6-Astra:

Detailed findings

Blocking issues

[Major] Apply diagonal stabilization before flooring negative flux

In src/random_ray/flat_source_domain.cpp, the strict estimator replaces a negative raw flux with the previous iterate before apply_transport_stabilization() runs.

With transport-corrected cross sections, a negative diagonal (within-group) scattering term can produce a negative intermediate source-iteration value. This is expected: OpenMC's existing diagonal stabilization transforms that raw value into the appropriate stabilized value. Flooring the value first prevents stabilization from seeing the intermediate result and can freeze the iteration at an incorrect solution.

For example, if

$$ D = -2,\qquad \phi^{(n)} = 1,\qquad \phi_{\mathrm{raw}}^{(n+1)} = -1, $$

the existing stabilization gives

$$ \phi^{(n+1)} = \frac{\phi_{\mathrm{raw}}^{(n+1)}-D\phi^{(n)}}{1-D} = \frac{-1-(-2)(1)}{3} = \frac{1}{3}. $$

Strict adaptive instead floors the raw value to the previous iterate, producing

$$ \frac{1-(-2)(1)}{3}=1, $$

so the iterate does not move.

I confirmed this with a homogeneous two-group eigenvalue problem using a negative diagonal scattering term:

  • Expected k-effective: $1/6$
  • adaptive: 0.1666666642
  • strict_adaptive: 2.5, unchanged through 200 batches

The relevant ordering is:

  1. add_source_to_scalar_flux() performs the strict rescue/floor.
  2. apply_transport_stabilization() runs afterward.

The positivity fix should therefore be applied after diagonal stabilization. If the naive-volume rescue is retained, the rescued candidate should also receive the corresponding stabilization before its sign is assessed.

The transport-corrected regression test currently exercises only adaptive. Please add a strict_adaptive case that verifies convergence to the expected result.

Non-blocking suggestions

[Moderate] Use the void-specific additive term when rescuing void flux

The rescue calculation assumes the non-void update

$$ \phi = \frac{T}{V} + \frac{q}{\Sigma_t} $$

and reconstructs the transport contribution by subtracting source(sr, g).

True void regions use a different update:

$$ \phi = \frac{T}{V} + \frac{1}{2}q_{\mathrm{ext}},\overline{\ell^2}. $$

Consequently, subtracting and restoring source(sr, g) does not algebraically reproduce the naive-volume update for a sourced void region.

Using the implemented routines with the same transport contribution, I obtained:

  • Naive-volume update: 4.5
  • Strict rescue: 0

The rescue should preserve the original transport contribution directly or subtract the actual additive term used by the void update.

paulromano and others added 4 commits September 9, 2026 10:37
The strict adaptive estimator floored a negative raw flux iterate at the
previous iterate before the diagonal stabilization ran. With transport
corrected cross sections a raw iterate driven negative by the negative
within-group scattering is expected, and the stabilization maps it to a
positive value, but the previous iterate is a fixed point of the
stabilization, so flooring first froze the iteration. On a homogeneous
two-group problem with an analytic eigenvalue of 0.5, the strict
estimator converged to a spurious 2.0.

The stabilization is now applied inside the flux update, to every
candidate value the update considers, so the fixup necessarily sees the
stabilized value and the rescued candidate is stabilized before its own
sign is assessed. The separate stabilization pass is removed, which
makes the wrong ordering impossible rather than avoided. The arithmetic
is unchanged, and the existing stabilization references for the hybrid
and adaptive estimators pass unmodified.

The rescue also reconstructed the transport contribution by subtracting
the reduced source, which is not the additive term of a void region's
update. One helper now supplies that term to both the update and the
rescue, so the rescue reproduces the naive-volume update for void and
material regions alike.

A short strict variant of the transport-corrected regression test pins
the corrected direction of the iteration toward the analytic answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
The file had grown one test function per estimator, against the
convention used by the other random ray regression tests, which
parametrize over the estimator with one stored reference per case. The
pin cell case now runs over the hybrid and adaptive estimators, with the
hybrid reference moved unchanged into its own directory, and the
homogeneous transport-corrected case runs over all three estimators.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
Resolves two conflicts. In constants.h the branch had moved the random
ray constants into flat_source_domain.h while develop added
WEIGHT_WINDOW_REL_TOL beside them, so only the new constant is kept. In
the diagonal stabilization test the branch had restructured the file
while develop renamed the convert_to_multigroup keyword to particles,
and both are kept.

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

jtramm commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @paulromano! I agree with both issues and was able to reproduce the diagonal stabilization issue on my end. I've added fixes for both.

@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.

Good to go now; I made one small update to avoid allocations when they weren't needed but everything else looked fine. Thanks for the improvement @jtramm!

@paulromano
paulromano enabled auto-merge (squash) September 9, 2026 23:07
@paulromano
paulromano merged commit 55bb333 into openmc-dev:develop Sep 9, 2026
16 checks passed
jtramm pushed a commit to jtramm/openmc that referenced this pull request Sep 10, 2026
Brings in the adaptive volume estimators (openmc-dev#4110). The source region
container now takes both the adaptive flags and the limiter's extent
flag, and the limiter block follows the adaptive estimator's
demoted-region gradient zeroing in the linear source update.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9rxXRzNh13xQBGzePHEeK
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