OpenMC/tests/unit_tests/test_stats.py
Paul Romano d56cda2544
Implement DecaySpectrum distribution type and utilize in R2S (#3930)
Co-authored-by: Copilot <copilot@github.com>
2026-05-08 20:53:12 -05:00

1148 lines
39 KiB
Python

from math import pi
from pathlib import Path
import numpy as np
import pytest
import openmc
import openmc.stats
from openmc.stats.univariate import _INTERPOLATION_SCHEMES, DecaySpectrum
from scipy.integrate import trapezoid
from tests.unit_tests import assert_sample_mean
@pytest.mark.flaky(reruns=1)
def test_discrete():
x = [0.0, 1.0, 10.0]
p = [0.3, 0.2, 0.5]
d = openmc.stats.Discrete(x, p)
elem = d.to_xml_element('distribution')
d = openmc.stats.Discrete.from_xml_element(elem)
np.testing.assert_array_equal(d.x, x)
np.testing.assert_array_equal(d.p, p)
assert len(d) == len(x)
d = openmc.stats.Univariate.from_xml_element(elem)
assert isinstance(d, openmc.stats.Discrete)
# Single point
d2 = openmc.stats.Discrete(1e6, 1.0)
assert d2.x == [1e6]
assert d2.p == [1.0]
assert len(d2) == 1
vals = np.array([1.0, 2.0, 3.0])
probs = np.array([0.1, 0.7, 0.2])
exp_mean = (vals * probs).sum()
d3 = openmc.stats.Discrete(vals, probs)
# sample discrete distribution and check that the mean of the samples is
# within 4 std. dev. of the expected mean
n_samples = 1_000_000
samples, weights = d3.sample(n_samples)
assert_sample_mean(samples, exp_mean)
assert np.all(weights == 1.0)
# Test biased distribution
d3.bias = np.array([0.2, 0.1, 0.7])
bias_elem = d3.to_xml_element('distribution')
d4 = openmc.stats.Univariate.from_xml_element(bias_elem)
np.testing.assert_array_equal(d4.bias, [0.2, 0.1, 0.7])
samples, weights = d4.sample(n_samples)
weighted_sample = samples * weights
assert_sample_mean(weighted_sample, exp_mean)
assert np.all(weights != 1.0)
def test_delta_function():
d = openmc.stats.delta_function(14.1e6)
assert isinstance(d, openmc.stats.Discrete)
np.testing.assert_array_equal(d.x, [14.1e6])
np.testing.assert_array_equal(d.p, [1.0])
def test_merge_discrete():
x1 = [0.0, 1.0, 10.0]
p1 = [0.3, 0.2, 0.5]
d1 = openmc.stats.Discrete(x1, p1)
x2 = [0.5, 1.0, 5.0]
p2 = [0.4, 0.5, 0.1]
d2 = openmc.stats.Discrete(x2, p2)
# Merged distribution should have x values sorted and probabilities
# appropriately combined. Duplicate x values should appear once.
merged = openmc.stats.Discrete.merge([d1, d2], [0.6, 0.4])
assert merged.x == pytest.approx([0.0, 0.5, 1.0, 5.0, 10.0])
assert merged.p == pytest.approx(
[0.6*0.3, 0.4*0.4, 0.6*0.2 + 0.4*0.5, 0.4*0.1, 0.6*0.5])
assert merged.integral() == pytest.approx(1.0)
# Probabilities add up but are not normalized
d1 = openmc.stats.Discrete([3.0], [1.0])
triple = openmc.stats.Discrete.merge([d1, d1, d1], [1.0, 2.0, 3.0])
assert triple.x == pytest.approx([3.0])
assert triple.p == pytest.approx([6.0])
assert triple.integral() == pytest.approx(6.0)
def test_merge_discrete_with_bias():
# Two discrete distributions with different biases
d1 = openmc.stats.Discrete([1.0, 2.0], [0.5, 0.5])
d2 = openmc.stats.Discrete([2.0, 3.0], [0.3, 0.7], bias=[0.1, 0.9])
merged = openmc.stats.Discrete.merge([d1, d2], [0.6, 0.4])
exp_mean = 0.6 * d1.mean() + 0.4 * d2.mean()
# Verify merged distribution has correct x values
assert set(merged.x) == {1.0, 2.0, 3.0}
# Bias should not be changed in original distributions
assert d1.bias is None
assert np.all(d2.bias == [0.1, 0.9])
# Sample and verify bias is applied correctly
samples, weights = merged.sample(10_000)
# Verify weighted mean matches expected unbiased mean
assert_sample_mean(samples*weights, exp_mean)
def test_clip_discrete():
# Create discrete distribution with two points that are not important, one
# because the x value is very small, and one because the p value is very
# small
d = openmc.stats.Discrete([1e-8, 1.0, 2.0, 1000.0], [3.0, 2.0, 5.0, 1e-12])
# Clipping the distribution should result in two points
d_clip = d.clip(1e-6)
assert d_clip.x.size == 2
assert d_clip.p.size == 2
# Make sure inplace returns same object
d_same = d.clip(1e-6, inplace=True)
assert d_same is d
with pytest.raises(ValueError):
d.clip(-1.)
with pytest.raises(ValueError):
d.clip(5)
@pytest.mark.flaky(reruns=1)
def test_uniform():
a, b = 10.0, 20.0
d = openmc.stats.Uniform(a, b)
elem = d.to_xml_element('distribution')
d = openmc.stats.Uniform.from_xml_element(elem)
assert d.a == a
assert d.b == b
assert len(d) == 2
t = d.to_tabular()
np.testing.assert_array_equal(t.x, [a, b])
np.testing.assert_array_equal(t.p, [1/(b-a), 1/(b-a)])
assert t.interpolation == 'histogram'
# Sample distribution and check that the mean of the samples is within 4
# std. dev. of the expected mean
exp_mean = 0.5 * (a + b)
n_samples = 1_000_000
samples, weights = d.sample(n_samples)
assert_sample_mean(samples, exp_mean)
assert np.all(weights == 1.0)
# Test biased distribution
d.bias = openmc.stats.PowerLaw(a, b, 2)
bias_elem = d.to_xml_element('distribution')
d2 = openmc.stats.Univariate.from_xml_element(bias_elem)
assert isinstance (d2.bias, openmc.stats.PowerLaw)
samples, weights = d2.sample(n_samples)
weighted_sample = samples * weights
assert_sample_mean(weighted_sample, exp_mean)
assert np.all(weights != 1.0)
@pytest.mark.flaky(reruns=1)
def test_powerlaw():
a, b, n = 10.0, 100.0, 2.0
d = openmc.stats.PowerLaw(a, b, n)
elem = d.to_xml_element('distribution')
d = openmc.stats.PowerLaw.from_xml_element(elem)
assert d.a == a
assert d.b == b
assert d.n == n
assert len(d) == 3
# Determine mean of distribution
exp_mean = (n+1)*(b**(n+2) - a**(n+2))/((n+2)*(b**(n+1) - a**(n+1)))
# sample power law distribution and check that the mean of the samples is
# within 4 std. dev. of the expected mean
n_samples = 1_000_000
samples, weights = d.sample(n_samples)
assert_sample_mean(samples, exp_mean)
assert np.all(weights == 1.0)
# Test biased distribution
d.bias = openmc.stats.Uniform(a, b)
bias_elem = d.to_xml_element('distribution')
d2 = openmc.stats.Univariate.from_xml_element(bias_elem)
assert isinstance (d2.bias, openmc.stats.Uniform)
samples, weights = d2.sample(n_samples)
weighted_sample = samples * weights
assert_sample_mean(weighted_sample, exp_mean)
assert np.all(weights != 1.0)
@pytest.mark.flaky(reruns=1)
def test_maxwell():
theta = 1.2895e6
d = openmc.stats.Maxwell(theta)
elem = d.to_xml_element('distribution')
d = openmc.stats.Maxwell.from_xml_element(elem)
assert d.theta == theta
assert len(d) == 1
exp_mean = 3/2 * theta
# sample maxwell distribution and check that the mean of the samples is
# within 4 std. dev. of the expected mean
n_samples = 1_000_000
samples, weights = d.sample(n_samples)
assert_sample_mean(samples, exp_mean)
assert np.all(weights == 1.0)
# A second sample starting from a different seed
samples_2, weights_2 = d.sample(n_samples)
assert_sample_mean(samples_2, exp_mean)
assert samples_2.mean() != samples.mean()
assert np.all(weights_2 == 1)
# Test biased distribution
d.bias = openmc.stats.Maxwell((theta * 1.1))
bias_elem = d.to_xml_element('distribution')
d2 = openmc.stats.Univariate.from_xml_element(bias_elem)
assert isinstance (d2.bias, openmc.stats.Maxwell)
samples, weights = d2.sample(n_samples)
weighted_sample = samples * weights
assert_sample_mean(weighted_sample, exp_mean)
assert np.all(weights != 1.0)
@pytest.mark.flaky(reruns=1)
def test_watt():
a, b = 0.965e6, 2.29e-6
d = openmc.stats.Watt(a, b)
elem = d.to_xml_element('distribution')
d = openmc.stats.Watt.from_xml_element(elem)
assert d.a == a
assert d.b == b
assert len(d) == 2
# mean value form adapted from
# "Prompt-fission-neutron average energy for 238U(n, f ) from
# threshold to 200 MeV" Ethvignot et. al.
# https://doi.org/10.1016/j.physletb.2003.09.048
exp_mean = 3/2 * a + a**2 * b / 4
# sample Watt distribution and check that the mean of the samples is within
# 4 std. dev. of the expected mean
n_samples = 1_000_000
samples, weights = d.sample(n_samples)
assert_sample_mean(samples, exp_mean)
assert np.all(weights == 1.0)
# Test biased distribution with 5 percent higher T_e
d.bias = openmc.stats.Watt(a*1.05, b)
bias_elem = d.to_xml_element('distribution')
d2 = openmc.stats.Univariate.from_xml_element(bias_elem)
assert isinstance (d2.bias, openmc.stats.Watt)
samples, weights = d2.sample(n_samples)
weighted_sample = samples * weights
assert_sample_mean(weighted_sample, exp_mean)
assert np.all(weights != 1.0)
@pytest.mark.flaky(reruns=1)
def test_tabular():
# test linear-linear sampling
x = np.array([0.001, 5.0, 7.0, 10.0])
p = np.array([10.0, 20.0, 5.0, 6.0])
d = openmc.stats.Tabular(x, p, 'linear-linear')
n_samples = 100_000
samples, weights = d.sample(n_samples)
assert_sample_mean(samples, d.mean())
assert np.all(weights == 1.0)
for scheme in _INTERPOLATION_SCHEMES:
# test sampling
d = openmc.stats.Tabular(x, p, scheme)
n_samples = 100_000
samples = d.sample(n_samples)[0]
assert_sample_mean(samples, d.mean())
# test histogram sampling
d = openmc.stats.Tabular(x, p, interpolation='histogram')
samples, weights = d.sample(n_samples)
assert_sample_mean(samples, d.mean())
assert np.all(weights == 1.0)
# Multiplying the probabilities should preserve the mean but change the integral
d2 = openmc.stats.Tabular(x, p*2, interpolation='histogram')
assert d2.mean() == pytest.approx(d.mean())
assert d2.integral() == pytest.approx(2.0*d.integral())
# Normalizing should result in an integral of 1
d.normalize()
assert d.integral() == pytest.approx(1.0)
# ensure that passing a set of probabilities shorter than x works
# for histogram interpolation
d = openmc.stats.Tabular(x, p[:-1], interpolation='histogram')
d.cdf()
d.mean()
assert_sample_mean(d.sample(n_samples)[0], d.mean())
# passing a shorter probability set should raise an error for linear-linear
with pytest.raises(ValueError):
d = openmc.stats.Tabular(x, p[:-1], interpolation='linear-linear')
d.cdf()
# Use probabilities of correct length for linear-linear interpolation and
# call the CDF method
d = openmc.stats.Tabular(x, p, interpolation='linear-linear')
d.cdf()
# Test biased distribution
d.bias = openmc.stats.Uniform(x[0], x[-1])
bias_elem = d.to_xml_element('distribution')
d2 = openmc.stats.Univariate.from_xml_element(bias_elem)
assert isinstance (d2.bias, openmc.stats.Uniform)
samples, weights = d2.sample(n_samples)
weighted_sample = samples * weights
assert_sample_mean(weighted_sample, d2.mean())
assert np.all(weights != 1.0)
def test_tabular_from_xml():
x = np.array([0.0, 5.0, 7.0, 10.0])
p = np.array([10.0, 20.0, 5.0, 6.0])
d = openmc.stats.Tabular(x, p, 'linear-linear')
elem = d.to_xml_element('distribution')
d = openmc.stats.Tabular.from_xml_element(elem)
assert all(d.x == x)
assert all(d.p == p)
assert d.interpolation == 'linear-linear'
assert len(d) == len(x)
# Make sure XML roundtrip works with len(x) == len(p) + 1
x = np.array([0.0, 5.0, 7.0, 10.0])
p = np.array([10.0, 20.0, 5.0])
d = openmc.stats.Tabular(x, p, 'histogram')
elem = d.to_xml_element('distribution')
d = openmc.stats.Tabular.from_xml_element(elem)
assert all(d.x == x)
assert all(d.p == p)
def test_legendre():
# Pu239 elastic scattering at 100 keV
coeffs = [1.000e+0, 1.536e-1, 1.772e-2, 5.945e-4, 3.497e-5, 1.881e-5]
d = openmc.stats.Legendre(coeffs)
assert d.coefficients == pytest.approx(coeffs)
assert len(d) == len(coeffs)
# Integrating distribution should yield one
mu = np.linspace(-1., 1., 1000)
assert trapezoid(d(mu), mu) == pytest.approx(1.0, rel=1e-4)
with pytest.raises(NotImplementedError):
d.to_xml_element('distribution')
@pytest.mark.flaky(reruns=1)
def test_mixture():
d1 = openmc.stats.Uniform(0, 5)
d2 = openmc.stats.Uniform(3, 7)
p = [0.5, 0.5]
mix = openmc.stats.Mixture(p, [d1, d2])
np.testing.assert_allclose(mix.probability, p)
assert mix.distribution == [d1, d2]
assert len(mix) == 4
# Sample and make sure sample mean is close to expected mean
n_samples = 1_000_000
samples, weights = mix.sample(n_samples)
assert_sample_mean(samples, (2.5 + 5.0)/2)
assert np.all(weights == 1.0)
elem = mix.to_xml_element('distribution')
d = openmc.stats.Mixture.from_xml_element(elem)
np.testing.assert_allclose(d.probability, p)
assert d.distribution == [d1, d2]
assert len(d) == 4
# Test biased sub-distribution
d.distribution[0].bias = openmc.stats.PowerLaw(0, 5, 2)
bias_elem = d.to_xml_element('distribution')
d3 = openmc.stats.Univariate.from_xml_element(bias_elem)
assert isinstance (d3.distribution[0].bias, openmc.stats.PowerLaw)
samples, weights = d3.sample(n_samples)
weighted_sample = samples * weights
assert_sample_mean(weighted_sample, (2.5 + 5.0)/2)
# Test biased meta-probability
d.distribution[0].bias = None
d.bias = [0.25, 0.75]
bias_elem_2 = d.to_xml_element('distribution')
d4 = openmc.stats.Univariate.from_xml_element(bias_elem_2)
assert isinstance (d4.bias, np.ndarray)
samples, weights = d4.sample(n_samples)
weighted_sample = samples * weights
assert_sample_mean(weighted_sample, (2.5 + 5.0)/2)
assert np.all(weights != 1.0)
def test_mixture_clip():
# Create mixture distribution containing a discrete distribution with two
# points that are not important, one because the x value is very small, and
# one because the p value is very small
d1 = openmc.stats.Discrete([1e-8, 1.0, 2.0, 1000.0], [3.0, 2.0, 5.0, 1e-12])
d2 = openmc.stats.Uniform(0, 5)
mix = openmc.stats.Mixture([0.5, 0.5], [d1, d2])
# Clipping should reduce the contained discrete distribution to 2 points
mix_clip = mix.clip(1e-6)
assert mix_clip.distribution[0].x.size == 2
assert mix_clip.distribution[0].p.size == 2
# Make sure inplace returns same object
mix_same = mix.clip(1e-6, inplace=True)
assert mix_same is mix
# Make sure clip removes low probability distributions
d_small = openmc.stats.Uniform(0., 1.)
d_large = openmc.stats.Uniform(2., 5.)
mix = openmc.stats.Mixture([1e-10, 1.0], [d_small, d_large])
mix_clip = mix.clip(1e-3)
assert mix_clip.distribution == [d_large]
# Make sure warning is raised if tolerance is exceeded
d1 = openmc.stats.Discrete([1.0, 1.001], [1.0, 0.7e-6])
d2 = openmc.stats.Tabular([0.0, 1.0], [0.7e-6], interpolation='histogram')
mix = openmc.stats.Mixture([1.0, 1.0], [d1, d2])
with pytest.warns(UserWarning):
mix_clip = mix.clip(1e-6)
# Make sure warning is raised if a biased Discrete is clipped
d3 = openmc.stats.Discrete([1.0, 1.001], [1.0, 0.7e-8])
d3.bias = [0.9, 0.1]
mix = openmc.stats.Mixture([1.0, 1.0], [d3, d2])
with pytest.raises(RuntimeError):
mix_clip = mix.clip(1e-6)
def test_polar_azimuthal():
# default polar-azimuthal should be uniform in mu and phi
d = openmc.stats.PolarAzimuthal()
assert isinstance(d.mu, openmc.stats.Uniform)
assert d.mu.a == -1.
assert d.mu.b == 1.
assert isinstance(d.phi, openmc.stats.Uniform)
assert d.phi.a == 0.
assert d.phi.b == 2*pi
mu = openmc.stats.Discrete(1., 1.)
phi = openmc.stats.Discrete(0., 1.)
d = openmc.stats.PolarAzimuthal(mu, phi)
assert d.mu == mu
assert d.phi == phi
elem = d.to_xml_element()
assert elem.tag == 'angle'
assert elem.attrib['type'] == 'mu-phi'
assert elem.find('mu') is not None
assert elem.find('phi') is not None
d = openmc.stats.PolarAzimuthal.from_xml_element(elem)
assert d.mu.x == [1.]
assert d.mu.p == [1.]
assert d.phi.x == [0.]
assert d.phi.p == [1.]
d = openmc.stats.UnitSphere.from_xml_element(elem)
assert isinstance(d, openmc.stats.PolarAzimuthal)
def test_isotropic():
d = openmc.stats.Isotropic()
mu = openmc.stats.Uniform(-1.0, 1.0)
phi = openmc.stats.PowerLaw(0., 2*np.pi, 2)
d2 = openmc.stats.PolarAzimuthal(mu, phi)
d.bias = d2
elem = d.to_xml_element()
assert elem.tag == 'angle'
assert elem.attrib['type'] == 'isotropic'
d = openmc.stats.Isotropic.from_xml_element(elem)
assert isinstance(d, openmc.stats.Isotropic)
assert isinstance(d.bias, openmc.stats.PolarAzimuthal)
def test_monodirectional():
d = openmc.stats.Monodirectional((1., 0., 0.))
elem = d.to_xml_element()
assert elem.tag == 'angle'
assert elem.attrib['type'] == 'monodirectional'
d = openmc.stats.Monodirectional.from_xml_element(elem)
assert d.reference_uvw == pytest.approx((1., 0., 0.))
def test_cartesian():
x = openmc.stats.Uniform(-10., 10.)
y = openmc.stats.Uniform(-10., 10.)
z = openmc.stats.Uniform(0., 20.)
z.bias = openmc.stats.PowerLaw(0., 20., 3)
d = openmc.stats.CartesianIndependent(x, y, z)
elem = d.to_xml_element()
assert elem.tag == 'space'
assert elem.attrib['type'] == 'cartesian'
assert elem.find('x') is not None
assert elem.find('y') is not None
d = openmc.stats.CartesianIndependent.from_xml_element(elem)
assert d.x == x
assert d.y == y
assert d.z == z
d = openmc.stats.Spatial.from_xml_element(elem)
assert isinstance(d, openmc.stats.CartesianIndependent)
assert isinstance (d.z.bias, openmc.stats.PowerLaw)
def test_box():
lower_left = (-10., -10., -10.)
upper_right = (10., 10., 10.)
d = openmc.stats.Box(lower_left, upper_right)
elem = d.to_xml_element()
assert elem.tag == 'space'
assert elem.attrib['type'] == 'box'
assert elem.find('parameters') is not None
d = openmc.stats.Box.from_xml_element(elem)
assert d.lower_left == pytest.approx(lower_left)
assert d.upper_right == pytest.approx(upper_right)
def test_point():
p = (-4., 2., 10.)
d = openmc.stats.Point(p)
elem = d.to_xml_element()
assert elem.tag == 'space'
assert elem.attrib['type'] == 'point'
assert elem.find('parameters') is not None
d = openmc.stats.Point.from_xml_element(elem)
assert d.xyz == pytest.approx(p)
def test_spherical_uniform():
r_outer = 2.0
r_inner = 1.0
thetas = (0.0, pi/2)
phis = (0.0, pi)
origin = (0.0, 1.0, 2.0)
sph_indep_function = openmc.stats.spherical_uniform(r_outer,
r_inner,
thetas,
phis,
origin)
assert isinstance(sph_indep_function, openmc.stats.SphericalIndependent)
def test_cylindrical_uniform():
r_outer = 2.0
r_inner = 1.0
height = 1.0
phis = (0.0, pi)
origin = (0.0, 1.0, 2.0)
dist = openmc.stats.cylindrical_uniform(r_outer, height, r_inner, phis,
origin=origin)
assert isinstance(dist, openmc.stats.CylindricalIndependent)
# Check r distribution (PowerLaw with exponent 1 for uniform area sampling)
assert isinstance(dist.r, openmc.stats.PowerLaw)
assert dist.r.a == pytest.approx(r_inner)
assert dist.r.b == pytest.approx(r_outer)
assert dist.r.n == pytest.approx(1.0)
# Check phi distribution
assert isinstance(dist.phi, openmc.stats.Uniform)
assert dist.phi.a == pytest.approx(phis[0])
assert dist.phi.b == pytest.approx(phis[1])
# Check z distribution (centered on origin along z_dir)
assert isinstance(dist.z, openmc.stats.Uniform)
assert dist.z.a == pytest.approx(-height / 2)
assert dist.z.b == pytest.approx(height / 2)
# Check origin and default directions
np.testing.assert_allclose(dist.origin, origin)
np.testing.assert_allclose(dist.r_dir, [1., 0., 0.])
np.testing.assert_allclose(dist.z_dir, [0., 0., 1.])
# XML round-trip preserves all parameters
elem = dist.to_xml_element()
dist2 = openmc.stats.CylindricalIndependent.from_xml_element(elem)
np.testing.assert_allclose(dist2.origin, origin)
np.testing.assert_allclose(dist2.r_dir, dist.r_dir)
np.testing.assert_allclose(dist2.z_dir, dist.z_dir)
def test_cylindrical_uniform_tilted():
# Test with non-default axis orientation (y-axis as cylinder axis)
dist = openmc.stats.cylindrical_uniform(
r_outer=3.0, height=2.0, r_dir=(1., 0., 0.), z_dir=(0., 1., 0.)
)
np.testing.assert_allclose(dist.z_dir, [0., 1., 0.])
np.testing.assert_allclose(dist.r_dir, [1., 0., 0.])
# XML round-trip preserves tilted directions
elem = dist.to_xml_element()
dist2 = openmc.stats.CylindricalIndependent.from_xml_element(elem)
np.testing.assert_allclose(dist2.z_dir, dist.z_dir)
np.testing.assert_allclose(dist2.r_dir, dist.r_dir)
def test_cylindrical_uniform_ring():
# height=0 should produce a flat ring (delta function at z=0)
r_outer = 2.0
r_inner = 1.0
phis = (0.0, pi)
origin = (0.0, 1.0, 2.0)
dist = openmc.stats.cylindrical_uniform(r_outer, 0.0, r_inner, phis,
origin=origin)
assert isinstance(dist, openmc.stats.CylindricalIndependent)
# Check r distribution
assert isinstance(dist.r, openmc.stats.PowerLaw)
assert dist.r.a == pytest.approx(r_inner)
assert dist.r.b == pytest.approx(r_outer)
assert dist.r.n == pytest.approx(1.0)
# Check phi distribution
assert isinstance(dist.phi, openmc.stats.Uniform)
assert dist.phi.a == pytest.approx(phis[0])
assert dist.phi.b == pytest.approx(phis[1])
# z distribution must be a delta function at 0.0 (local frame)
assert isinstance(dist.z, openmc.stats.Discrete)
assert dist.z.x[0] == pytest.approx(0.0)
# XML round-trip
elem = dist.to_xml_element()
dist2 = openmc.stats.CylindricalIndependent.from_xml_element(elem)
np.testing.assert_allclose(dist2.origin, origin)
np.testing.assert_allclose(dist2.r_dir, dist.r_dir)
np.testing.assert_allclose(dist2.z_dir, dist.z_dir)
@pytest.mark.flaky(reruns=1)
def test_normal():
mean = 10.0
std_dev = 2.0
d = openmc.stats.Normal(mean,std_dev)
elem = d.to_xml_element('distribution')
assert elem.attrib['type'] == 'normal'
d = openmc.stats.Normal.from_xml_element(elem)
assert d.mean_value == pytest.approx(mean)
assert d.std_dev == pytest.approx(std_dev)
assert len(d) == 2
# sample normal distribution
n_samples = 100_000
samples, weights = d.sample(n_samples)
assert_sample_mean(samples, mean)
assert np.all(weights == 1.0)
# Test biased distribution
d.bias = openmc.stats.Normal(10.0, 4.0)
bias_elem = d.to_xml_element('distribution')
d2 = openmc.stats.Univariate.from_xml_element(bias_elem)
assert isinstance (d2.bias, openmc.stats.Normal)
samples, weights = d2.sample(n_samples)
weighted_sample = samples * weights
assert_sample_mean(weighted_sample, mean)
assert np.all(weights != 1.0)
@pytest.mark.flaky(reruns=1)
def test_normal_truncated():
mean = 10.0
std_dev = 2.0
lower = 6.0
upper = 14.0
d = openmc.stats.Normal(mean, std_dev, lower, upper)
# Check attributes
assert d.mean_value == pytest.approx(mean)
assert d.std_dev == pytest.approx(std_dev)
assert d.lower == pytest.approx(lower)
assert d.upper == pytest.approx(upper)
assert len(d) == 4
assert d.support == (lower, upper)
# Test XML round-trip
elem = d.to_xml_element('distribution')
assert elem.attrib['type'] == 'normal'
params = elem.attrib['parameters'].split()
assert len(params) == 4
d2 = openmc.stats.Normal.from_xml_element(elem)
assert d2.mean_value == pytest.approx(mean)
assert d2.std_dev == pytest.approx(std_dev)
assert d2.lower == pytest.approx(lower)
assert d2.upper == pytest.approx(upper)
# Test PDF evaluation
# PDF should be zero outside bounds
assert d.evaluate(lower - 1.0) == 0.0
assert d.evaluate(upper + 1.0) == 0.0
# PDF should be positive inside bounds
assert d.evaluate(mean) > 0.0
# PDF should be higher than untruncated at the mean (due to renormalization)
d_unbounded = openmc.stats.Normal(mean, std_dev)
assert d.evaluate(mean) > d_unbounded.evaluate(mean)
# Verify that PDF integrates to approximately 1
x = np.linspace(lower, upper, 1000)
integral = trapezoid(d.evaluate(x), x)
assert integral == pytest.approx(1.0, rel=0.01)
# Sample truncated distribution
n_samples = 10_000
samples, weights = d.sample(n_samples)
# All samples should be within bounds
assert np.all(samples >= lower)
assert np.all(samples <= upper)
# Weights should all be 1 (no biasing)
assert np.all(weights == 1.0)
def test_normal_truncated_one_sided():
# Test lower-bounded only (positive half-normal centered at 0)
d_lower = openmc.stats.Normal(0.0, 1.0, lower=0.0)
assert d_lower.lower == 0.0
assert d_lower.upper == np.inf
assert d_lower.evaluate(-1.0) == 0.0
assert d_lower.evaluate(1.0) > 0.0
# PDF at 0 should be approximately 2 * 0.3989 ≈ 0.798 (half-normal)
assert d_lower.evaluate(0.0) == pytest.approx(0.798, rel=0.01)
# Test upper-bounded only
d_upper = openmc.stats.Normal(0.0, 1.0, upper=0.0)
assert d_upper.lower == -np.inf
assert d_upper.upper == 0.0
assert d_upper.evaluate(1.0) == 0.0
assert d_upper.evaluate(-1.0) > 0.0
def test_normal_truncated_errors():
# Invalid bounds (lower >= upper)
with pytest.raises(ValueError):
openmc.stats.Normal(0.0, 1.0, lower=1.0, upper=0.0)
with pytest.raises(ValueError):
openmc.stats.Normal(0.0, 1.0, lower=1.0, upper=1.0)
@pytest.mark.flaky(reruns=1)
def test_muir():
mean = 10.0
mass = 5.0
temp = 20000.
d = openmc.stats.muir(mean, mass, temp)
assert isinstance(d, openmc.stats.Normal)
elem = d.to_xml_element('energy')
assert elem.attrib['type'] == 'normal'
d = openmc.stats.Univariate.from_xml_element(elem)
assert isinstance(d, openmc.stats.Normal)
# sample muir distribution
n_samples = 100_000
samples, weights = d.sample(n_samples)
assert_sample_mean(samples, mean)
assert np.all(weights == 1.0)
@pytest.mark.flaky(reruns=1)
def test_combine_distributions():
# Combine two discrete (same data as in test_merge_discrete)
x1 = [0.0, 1.0, 10.0]
p1 = [0.3, 0.2, 0.5]
d1 = openmc.stats.Discrete(x1, p1)
x2 = [0.5, 1.0, 5.0]
p2 = [0.4, 0.5, 0.1]
d2 = openmc.stats.Discrete(x2, p2)
# Merged distribution should have x values sorted and probabilities
# appropriately combined. Duplicate x values should appear once.
merged = openmc.stats.combine_distributions([d1, d2], [0.6, 0.4])
assert isinstance(merged, openmc.stats.Discrete)
assert merged.x == pytest.approx([0.0, 0.5, 1.0, 5.0, 10.0])
assert merged.p == pytest.approx(
[0.6*0.3, 0.4*0.4, 0.6*0.2 + 0.4*0.5, 0.4*0.1, 0.6*0.5])
# Probabilities add up but are not normalized
d1 = openmc.stats.Discrete([3.0], [1.0])
triple = openmc.stats.combine_distributions([d1, d1, d1], [1.0, 2.0, 3.0])
assert triple.x == pytest.approx([3.0])
assert triple.p == pytest.approx([6.0])
# Combine discrete and tabular
t1 = openmc.stats.Tabular(x2, p2)
mixed = openmc.stats.combine_distributions([d1, t1], [0.5, 0.5])
assert isinstance(mixed, openmc.stats.Mixture)
assert len(mixed.distribution) == 2
assert len(mixed.probability) == 2
assert mixed == openmc.stats.combine_distributions([mixed], [1.0])
# Mixture combined with another distribution: probabilities should be
# correctly scaled when the Mixture is flattened
d_a = openmc.stats.delta_function(1.0)
d_b = openmc.stats.delta_function(2.0)
m = openmc.stats.Mixture([0.3, 0.7], [d_a, d_b])
extra = openmc.stats.delta_function(3.0)
result = openmc.stats.combine_distributions([m, extra], [0.5, 0.5])
assert isinstance(result, openmc.stats.Discrete)
assert result.x == pytest.approx([1.0, 2.0, 3.0])
assert result.p == pytest.approx([0.5*0.3, 0.5*0.7, 0.5])
# Passing a Mixture with a bias should warn that the bias is dropped
biased_m = openmc.stats.Mixture([0.5, 0.5], [d_a, d_b], bias=[0.8, 0.2])
with pytest.warns(UserWarning, match='bias'):
openmc.stats.combine_distributions([biased_m], [1.0])
# Single tabular returns a tabular distribution with scaled probabilities
t_single = openmc.stats.Tabular([0.0, 1.0], [2.0, 0.0])
scaled = openmc.stats.combine_distributions([t_single], [0.25])
assert isinstance(scaled, openmc.stats.Tabular)
assert scaled.p == pytest.approx([0.5, 0.0])
# Mixture with biased tabular should preserve unbiased mean via weights
bias = openmc.stats.Tabular([0.0, 1.0], [2.0, 0.0])
t_biased = openmc.stats.Tabular([0.0, 1.0], [1.0, 1.0], bias=bias)
d1 = openmc.stats.delta_function(0.0)
mixed = openmc.stats.combine_distributions([t_biased, d1], [0.5, 0.5])
assert isinstance(mixed, openmc.stats.Mixture)
samples, weights = mixed.sample(10_000)
assert_sample_mean(samples*weights, 0.25)
# Combine 1 discrete and 2 tabular -- the tabular distributions should
# combine to produce a uniform distribution with mean 0.5. The combined
# distribution should have a mean of 0.25.
t1 = openmc.stats.Tabular([0., 1.], [2.0, 0.0])
t2 = openmc.stats.Tabular([0., 1.], [0.0, 2.0])
d1 = openmc.stats.delta_function(0.0)
combined = openmc.stats.combine_distributions([t1, t2, d1], [0.25, 0.25, 0.5])
assert combined.integral() == pytest.approx(1.0)
# Sample the combined distribution and make sure the sample mean is within
# uncertainty of the expected value
samples, weights = combined.sample(10_000)
assert_sample_mean(samples, 0.25)
assert np.all(weights == 1.0)
# If biased/unbiased Discrete distributions are combined, unbiased probability
# should be conserved and points from both original distributions should be
# assigned bias probabilities.
x1 = [0.0, 1.0, 10.0]
p1 = [0.3, 0.2, 0.5]
b1 = [0.2, 0.5, 0.3]
d1 = openmc.stats.Discrete(x1, p1, b1)
x2 = [0.5, 1.0, 5.0]
p2 = [0.4, 0.5, 0.1]
d2 = openmc.stats.Discrete(x2, p2)
combined = openmc.stats.combine_distributions([d1, d2, t1], [0.25, 0.25, 0.5])
p3 = [0.075, 0.1, 0.175, 0.025, 0.125]
b3 = [0.05, 0.1, 0.25, 0.025, 0.075]
assert all(combined.distribution[-1].p == p3)
assert all(combined.distribution[-1].bias == b3)
def test_reference_vwu_projection():
"""When a non-orthogonal vector is provided, the setter should project out
any component along reference_uvw so the stored vector is orthogonal.
"""
pa = openmc.stats.PolarAzimuthal() # default reference_uvw == (0, 0, 1)
# Provide a vector that is not orthogonal to (0,0,1)
pa.reference_vwu = (2.0, 0.5, 0.3)
reference_v = np.asarray(pa.reference_vwu)
reference_u = np.asarray(pa.reference_uvw)
# reference_v should be orthogonal to reference_u
assert abs(np.dot(reference_v, reference_u)) < 1e-6
def test_reference_vwu_normalization():
"""When a non-normalized vector is provided, the setter should normalize
the projected vector to unit length.
"""
pa = openmc.stats.PolarAzimuthal() # default reference_uvw == (0, 0, 1)
# Provide a vector that is neither orthogonal to (0,0,1) nor unit-length
pa.reference_vwu = (2.0, 0.5, 0.3)
reference_v = np.asarray(pa.reference_vwu)
# reference_v should be unit length
assert np.isclose(np.linalg.norm(reference_v), 1.0, atol=1e-12)
def test_fusion_spectrum_dd():
d = openmc.stats.fusion_neutron_spectrum(10e3, 'DD')
assert isinstance(d, openmc.stats.Normal)
# E_0 for D(d,n)3He is ~2.45 MeV; thermal shift at 10 keV should be
# several tens of keV, so mean should be noticeably above E_0
assert d.mean_value > 2.45e6
assert d.mean_value < 2.6e6
# Standard deviation should be positive and on order of ~50-100 keV
assert d.std_dev > 30e3
assert d.std_dev < 200e3
def test_fusion_spectrum_dt():
d = openmc.stats.fusion_neutron_spectrum(10e3, 'DT')
assert isinstance(d, openmc.stats.Normal)
# E_0 for T(d,n)alpha is ~14.02 MeV; with thermal shift mean should be
# above E_0 by several tens of keV
assert d.mean_value > 14.02e6
assert d.mean_value < 14.2e6
# Standard deviation should be on order of ~200-400 keV
assert d.std_dev > 100e3
assert d.std_dev < 500e3
def test_fusion_spectrum_temp_continuity():
# Verify the low-T and high-T formulas produce nearly identical results
# at the 40 keV switchover point
d_lo = openmc.stats.fusion_neutron_spectrum(39.99e3, 'DT')
d_hi = openmc.stats.fusion_neutron_spectrum(40.01e3, 'DT')
assert d_lo.mean_value == pytest.approx(d_hi.mean_value, rel=1e-3)
assert d_lo.std_dev == pytest.approx(d_hi.std_dev, rel=1e-3)
# Same check for DD
d_lo = openmc.stats.fusion_neutron_spectrum(39.99e3, 'DD')
d_hi = openmc.stats.fusion_neutron_spectrum(40.01e3, 'DD')
assert d_lo.mean_value == pytest.approx(d_hi.mean_value, rel=1e-3)
assert d_lo.std_dev == pytest.approx(d_hi.std_dev, rel=1e-3)
def test_fusion_spectrum_high_temp():
# At T_i = 80 keV (high-T regime), ensure the function still produces
# reasonable results using Table IV formulas
for reactants in ('DD', 'DT'):
d = openmc.stats.fusion_neutron_spectrum(80e3, reactants)
assert isinstance(d, openmc.stats.Normal)
assert d.mean_value > 0
assert d.std_dev > 0
# DT mean at 80 keV should be higher than at 10 keV
d_10 = openmc.stats.fusion_neutron_spectrum(10e3, 'DT')
d_80 = openmc.stats.fusion_neutron_spectrum(80e3, 'DT')
assert d_80.mean_value > d_10.mean_value
assert d_80.std_dev > d_10.std_dev
def test_fusion_spectrum_zero_temp():
# At very low temperature, mean should approach E_0 and width should
# approach zero
d = openmc.stats.fusion_neutron_spectrum(1.0, 'DT')
assert d.mean_value == pytest.approx(14.049e6, rel=1e-3)
assert d.std_dev < 5e3 # width approaches zero at low temperature
def test_fusion_spectrum_invalid():
# Invalid reactant string should raise an error
with pytest.raises(ValueError):
openmc.stats.fusion_neutron_spectrum(10e3, '🐔🧇')
# Negative temperature should raise an error
with pytest.raises(ValueError):
openmc.stats.fusion_neutron_spectrum(-10e3, 'DT')
# Temperature above 100 keV should raise an error
with pytest.raises(ValueError):
openmc.stats.fusion_neutron_spectrum(101e3, 'DT')
@pytest.fixture(autouse=False)
def decay_spectrum_chain():
"""Set chain_file for the duration of a test and clear the _photon_integral
cache so results from a different chain don't bleed across tests."""
CHAIN_FILE = (Path(__file__).parents[1] / 'chain_simple.xml').resolve()
DecaySpectrum._photon_integral.cache_clear()
with openmc.config.patch('chain_file', CHAIN_FILE):
yield
DecaySpectrum._photon_integral.cache_clear()
def test_decay_spectrum_construction():
nuclides = {'I135': 1.5e-3, 'Xe135': 8.2e-4}
d = openmc.stats.DecaySpectrum(nuclides, volume=100.0)
assert d.nuclides == nuclides
assert d.volume == pytest.approx(100.0)
assert len(d) == 2
def test_decay_spectrum_validation():
# nuclides must be a dict
with pytest.raises(TypeError):
openmc.stats.DecaySpectrum(['I135'], volume=1.0)
# densities must be > 0
with pytest.raises(ValueError):
openmc.stats.DecaySpectrum({'I135': -1.0}, volume=1.0)
# volume must be > 0
with pytest.raises(ValueError):
openmc.stats.DecaySpectrum({'I135': 1e-3}, volume=-1.0)
with pytest.raises(ValueError):
openmc.stats.DecaySpectrum({'I135': 1e-3}, volume=0.0)
def test_decay_spectrum_xml_roundtrip():
nuclides = {'I135': 1.5e-3, 'Xe135': 8.2e-4}
d = openmc.stats.DecaySpectrum(nuclides, volume=100.0)
elem = d.to_xml_element('energy')
assert elem.get('type') == 'decay_spectrum'
assert float(elem.get('volume')) == pytest.approx(100.0)
assert elem.findtext('nuclides').split() == list(nuclides)
assert [float(x) for x in elem.findtext('parameters').split()] == pytest.approx(
list(nuclides.values()))
# Round-trip via DecaySpectrum.from_xml_element
d2 = openmc.stats.DecaySpectrum.from_xml_element(elem)
assert d2.nuclides == nuclides
assert d2.volume == pytest.approx(100.0)
# Round-trip via the Univariate dispatcher
d3 = openmc.stats.Univariate.from_xml_element(elem)
assert isinstance(d3, openmc.stats.DecaySpectrum)
assert d3 == d
def test_decay_spectrum_to_distribution(decay_spectrum_chain):
# Single emitting nuclide -> concrete distribution, not None
d = openmc.stats.DecaySpectrum({'I135': 1e-3}, volume=10.0)
dist = d.to_distribution()
assert dist is not None
# Result is cached on second call
dist2 = d.to_distribution()
assert dist2 is dist
# Nuclide with no photon source -> None
d_stable = openmc.stats.DecaySpectrum({'Xe136': 1e-3}, volume=10.0)
assert d_stable.to_distribution() is None
# Mixture of emitters -> non-None combined distribution
d_mix = openmc.stats.DecaySpectrum(
{'I135': 1e-3, 'Xe135': 5e-4}, volume=10.0
)
dist_mix = d_mix.to_distribution()
assert dist_mix is not None
def test_decay_spectrum_integral(decay_spectrum_chain):
# For an emitting nuclide, integral should be > 0
d = openmc.stats.DecaySpectrum({'I135': 1e-3}, volume=10.0)
assert d.integral() > 0.0
# Proportional to density: doubling density doubles integral
d2 = openmc.stats.DecaySpectrum({'I135': 2e-3}, volume=10.0)
assert d2.integral() == pytest.approx(2.0 * d.integral())
# Proportional to volume
d3 = openmc.stats.DecaySpectrum({'I135': 1e-3}, volume=20.0)
assert d3.integral() == pytest.approx(2.0 * d.integral())
# Pure non-emitter -> 0.0
d_stable = openmc.stats.DecaySpectrum({'Xe136': 1e-3}, volume=10.0)
assert d_stable.integral() == pytest.approx(0.0)
def test_decay_spectrum_clip(decay_spectrum_chain):
# Stable / non-emitting nuclides are removed unconditionally
d = openmc.stats.DecaySpectrum(
{'I135': 1e-3, 'Xe135': 5e-4, 'Xe136': 1.0, 'Cs135': 1.0},
volume=10.0,
)
d_clip = d.clip()
assert 'Xe136' not in d_clip.nuclides
assert 'Cs135' not in d_clip.nuclides
assert 'I135' in d_clip.nuclides
assert 'Xe135' in d_clip.nuclides
# Original is unchanged
assert 'Xe136' in d.nuclides
# inplace=True modifies and returns the same object
d_same = d.clip(inplace=True)
assert d_same is d
assert 'Xe136' not in d.nuclides
# A nuclide with negligible emission rate is removed by tolerance clipping.
# U235 has a very small integral (~4e-17 Bq/atom) compared with I135 (~4e-5)
d_tight = openmc.stats.DecaySpectrum(
{'I135': 1e-3, 'U235': 1e-3}, volume=10.0
)
d_tight_clip = d_tight.clip(tolerance=1e-9)
assert 'U235' not in d_tight_clip.nuclides
assert 'I135' in d_tight_clip.nuclides
# All non-emitters -> empty nuclides dict
d_empty = openmc.stats.DecaySpectrum({'Xe136': 1e-3}, volume=10.0)
d_empty.clip(inplace=True)
assert d_empty.nuclides == {}