More interpolation types in Tabular. (#3413)

Co-authored-by: Paul Romano <paul.k.romano@gmail.com>
This commit is contained in:
GuySten 2026-02-11 23:05:07 +02:00 committed by GitHub
parent 360ec24b41
commit 96383fcb2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 201 additions and 45 deletions

View file

@ -201,6 +201,18 @@ std::complex<double> faddeeva(std::complex<double> z);
//! \return Derivative of Faddeeva function evaluated at z
std::complex<double> w_derivative(std::complex<double> z, int order);
//! Evaluate relative exponential function
//!
//! \param x Real argument
//! \return (exp(x)-1)/x without loss of precision near 0
double exprel(double x);
//! Evaluate relative logarithm function
//!
//! \param x Real argument
//! \return log(1+x)/x without loss of precision near 0
double log1prel(double x);
//! Helper function to get index and interpolation function on an incident
//! energy grid
//!

View file

@ -10,6 +10,7 @@ from warnings import warn
import lxml.etree as ET
import numpy as np
from scipy.integrate import trapezoid
from scipy.special import exprel, hyp1f1, lambertw
import scipy
import openmc.checkvalue as cv
@ -25,6 +26,16 @@ _INTERPOLATION_SCHEMES = {
}
def exprel2(x):
"""Evaluate 2*(exp(x)-1-x)/x^2 without loss of precision near 0"""
return hyp1f1(1, 3, x)
def log1prel(x):
"""Evaluate log(1+x)/x without loss of precision near 0"""
return np.where(np.abs(x) < 1e-16, 1.0, np.log1p(x) / x)
class Univariate(EqualityMixin, ABC):
"""Probability distribution of a single random variable.
@ -1299,44 +1310,64 @@ class Tabular(Univariate):
c[1:] = p[:x.size-1] * np.diff(x)
elif self.interpolation == 'linear-linear':
c[1:] = 0.5 * (p[:-1] + p[1:]) * np.diff(x)
elif self.interpolation == "linear-log":
m = np.diff(p) / np.diff(np.log(x))
c[1:] = p[:-1] * np.diff(x) + m * (
x[1:] * (np.diff(np.log(x)) - 1.0) + x[:-1]
)
elif self.interpolation == "log-linear":
m = np.diff(np.log(p)) / np.diff(x)
c[1:] = p[:-1] * np.diff(x) * exprel(m * np.diff(x))
elif self.interpolation == "log-log":
m = np.diff(np.log(x * p)) / np.diff(np.log(x))
c[1:] = (x * p)[:-1] * np.diff(np.log(x)) * exprel(m * np.diff(np.log(x)))
else:
raise NotImplementedError('Can only generate CDFs for tabular '
'distributions using histogram or '
'linear-linear interpolation')
raise NotImplementedError(
f"Cannot generate CDFs for tabular "
f"distributions using {self.interpolation} interpolation"
)
return np.cumsum(c)
def mean(self):
"""Compute the mean of the tabular distribution"""
if self.interpolation == 'linear-linear':
mean = 0.0
for i in range(1, len(self.x)):
y_min = self.p[i-1]
y_max = self.p[i]
x_min = self.x[i-1]
x_max = self.x[i]
m = (y_max - y_min) / (x_max - x_min)
# use normalized probabilities when computing mean
p = self.p / self.cdf().max()
x = self.x
x_min = x[:-1]
x_max = x[1:]
p_min = p[: x.size - 1]
exp_val = (1./3.) * m * (x_max**3 - x_min**3)
exp_val += 0.5 * m * x_min * (x_min**2 - x_max**2)
exp_val += 0.5 * y_min * (x_max**2 - x_min**2)
mean += exp_val
elif self.interpolation == 'histogram':
x_l = self.x[:-1]
x_r = self.x[1:]
p_l = self.p[:self.x.size-1]
mean = (0.5 * (x_l + x_r) * (x_r - x_l) * p_l).sum()
if self.interpolation == "linear-linear":
m = np.diff(p) / np.diff(x)
mean = ((1.0 / 3.0) * m * np.diff(x**3)
+ 0.5 * (p_min - m * x_min) * np.diff(x**2)).sum()
elif self.interpolation == "linear-log":
m = np.diff(p) / np.diff(np.log(x))
mean = (
(1.0 / 4.0) * m * x_min**2
* ((x_max / x_min)**2 * (2 * np.diff(np.log(x)) - 1) + 1)
+ 0.5 * p_min * np.diff(x**2)
).sum()
elif self.interpolation == "log-linear":
m = np.diff(np.log(p)) / np.diff(x)
mean = (p_min * (
np.diff(x) ** 2
* ((0.5 * exprel2(m * np.diff(x)) * (m * np.diff(x) - 1) + 1))
+ np.diff(x) * x_min * exprel(m * np.diff(x)))
).sum()
elif self.interpolation == "log-log":
m = np.diff(np.log(p)) / np.diff(np.log(x))
mean = (p_min * x_min**2 * np.diff(np.log(x))
* exprel((m + 2) * np.diff(np.log(x)))).sum()
elif self.interpolation == "histogram":
mean = (0.5 * (x_min + x_max) * np.diff(x) * p_min).sum()
else:
raise NotImplementedError('Can only compute mean for tabular '
'distributions using histogram '
'or linear-linear interpolation.')
# Normalize for when integral of distribution is not 1
mean /= self.integral()
raise NotImplementedError(
f"Cannot compute mean for tabular "
f"distributions using {self.interpolation} interpolation"
)
return mean
def normalize(self):
@ -1395,11 +1426,56 @@ class Tabular(Univariate):
quad[quad < 0.0] = 0.0
m[non_zero] = x_i[non_zero] + (np.sqrt(quad) - p_i[non_zero]) / m[non_zero]
samples_out = m
elif self.interpolation == "linear-log":
# get variable and probability values for the
# next entry
x_i1 = self.x[cdf_idx + 1]
p_i1 = p[cdf_idx + 1]
# compute slope between entries
m = (p_i1 - p_i) / np.log(x_i1 / x_i)
# set values for zero slope
zero = m == 0.0
m[zero] = x_i[zero] + (xi[zero] - c_i[zero]) / p_i[zero]
positive = m > 0
negative = m < 0
a = p_i / m - 1
m[positive] = (
x_i
* ((xi - c_i) / (m * x_i) + a)
/ np.real(lambertw((((xi - c_i) / (m * x_i) + a)) * np.exp(a)))
)[positive]
m[negative] = (
x_i
* ((xi - c_i) / (m * x_i) + a)
/ np.real(lambertw((((xi - c_i) / (m * x_i) + a)) * np.exp(a), -1.0))
)[negative]
samples_out = m
elif self.interpolation == "log-linear":
# get variable and probability values for the
# next entry
x_i1 = self.x[cdf_idx + 1]
p_i1 = p[cdf_idx + 1]
# compute slope between entries
m = np.log(p_i1 / p_i) / (x_i1 - x_i)
f = (xi - c_i) / p_i
samples_out = x_i + f * log1prel(m * f)
elif self.interpolation == "log-log":
# get variable and probability values for the
# next entry
x_i1 = self.x[cdf_idx + 1]
p_i1 = p[cdf_idx + 1]
# compute slope between entries
m = np.log((x_i1 * p_i1) / (x_i * p_i)) / np.log(x_i1 / x_i)
f = (xi - c_i) / (x_i * p_i)
samples_out = x_i * np.exp(f * log1prel(m * f))
else:
raise NotImplementedError('Can only sample tabular distributions '
'using histogram or '
'linear-linear interpolation')
raise NotImplementedError(
f"Cannot sample tabular distributions "
f"for {self.inteprolation} interpolation "
)
assert all(samples_out < self.x[-1])
return samples_out
@ -1497,9 +1573,22 @@ class Tabular(Univariate):
return np.sum(np.diff(self.x) * self.p[:self.x.size-1])
elif self.interpolation == 'linear-linear':
return trapezoid(self.p, self.x)
elif self.interpolation == "linear-log":
m = np.diff(self.p) / np.diff(np.log(self.x))
return np.sum(
self.p[:-1] * np.diff(self.x)
+ m * (self.x[1:] * (np.diff(np.log(self.x)) - 1.0) + self.x[:-1])
)
elif self.interpolation == "log-linear":
m = np.diff(np.log(self.p)) / np.diff(self.x)
return np.sum(self.p[:-1] * np.diff(self.x) * exprel(m * np.diff(self.x)))
elif self.interpolation == "log-log":
m = np.diff(np.log(self.p)) / np.diff(np.log(self.x))
return np.sum(self.p[:-1] * self.x[:-1] * np.diff(np.log(self.x))
* exprel((m + 1) * np.diff(np.log(self.x))))
else:
raise NotImplementedError(
f'integral() not supported for {self.inteprolation} interpolation')
f'integral() not supported for {self.interpolation} interpolation')
class Legendre(Univariate):

View file

@ -401,6 +401,10 @@ Tabular::Tabular(pugi::xml_node node)
interp_ = Interpolation::histogram;
} else if (temp == "linear-linear") {
interp_ = Interpolation::lin_lin;
} else if (temp == "log-linear") {
interp_ = Interpolation::log_lin;
} else if (temp == "log-log") {
interp_ = Interpolation::log_log;
} else {
openmc::fatal_error(
"Unsupported interpolation type for distribution: " + temp);
@ -437,13 +441,6 @@ void Tabular::init(
std::copy(x, x + n, std::back_inserter(x_));
std::copy(p, p + n, std::back_inserter(p_));
// Check interpolation parameter
if (interp_ != Interpolation::histogram &&
interp_ != Interpolation::lin_lin) {
openmc::fatal_error("Only histogram and linear-linear interpolation "
"for tabular distribution is supported.");
}
// Calculate cumulative distribution function
if (c) {
std::copy(c, c + n, std::back_inserter(c_));
@ -455,6 +452,18 @@ void Tabular::init(
c_[i] = c_[i - 1] + p_[i - 1] * (x_[i] - x_[i - 1]);
} else if (interp_ == Interpolation::lin_lin) {
c_[i] = c_[i - 1] + 0.5 * (p_[i - 1] + p_[i]) * (x_[i] - x_[i - 1]);
} else if (interp_ == Interpolation::log_lin) {
double m = std::log(p_[i] / p_[i - 1]) / (x_[i] - x_[i - 1]);
c_[i] = c_[i - 1] + p_[i - 1] * (x_[i] - x_[i - 1]) *
exprel(m * (x_[i] - x_[i - 1]));
} else if (interp_ == Interpolation::log_log) {
double m = std::log((x_[i] * p_[i]) / (x_[i - 1] * p_[i - 1])) /
std::log(x_[i] / x_[i - 1]);
c_[i] = c_[i - 1] + x_[i - 1] * p_[i - 1] *
std::log(x_[i] / x_[i - 1]) *
exprel(m * std::log(x_[i] / x_[i - 1]));
} else {
UNREACHABLE();
}
}
}
@ -495,7 +504,7 @@ double Tabular::sample_unbiased(uint64_t* seed) const
} else {
return x_i;
}
} else {
} else if (interp_ == Interpolation::lin_lin) {
// Linear-linear interpolation
double x_i1 = x_[i + 1];
double p_i1 = p_[i + 1];
@ -508,6 +517,24 @@ double Tabular::sample_unbiased(uint64_t* seed) const
(std::sqrt(std::max(0.0, p_i * p_i + 2 * m * (c - c_i))) - p_i) /
m;
}
} else if (interp_ == Interpolation::log_lin) {
// Log-linear interpolation
double x_i1 = x_[i + 1];
double p_i1 = p_[i + 1];
double m = std::log(p_i1 / p_i) / (x_i1 - x_i);
double f = (c - c_i) / p_i;
return x_i + f * log1prel(m * f);
} else if (interp_ == Interpolation::log_log) {
// Log-Log interpolation
double x_i1 = x_[i + 1];
double p_i1 = p_[i + 1];
double m = std::log((x_i1 * p_i1) / (x_i * p_i)) / std::log(x_i1 / x_i);
double f = (c - c_i) / (p_i * x_i);
return x_i * std::exp(f * log1prel(m * f));
} else {
UNREACHABLE();
}
}

View file

@ -919,6 +919,24 @@ std::complex<double> w_derivative(std::complex<double> z, int order)
}
}
double exprel(double x)
{
if (std::abs(x) < 1e-16)
return 1.0;
else {
return std::expm1(x) / x;
}
}
double log1prel(double x)
{
if (std::abs(x) < 1e-16)
return 1.0;
else {
return std::log1p(x) / x;
}
}
// Helper function to get index and interpolation function on an incident energy
// grid
void get_energy_index(

View file

@ -4,6 +4,7 @@ import numpy as np
import pytest
import openmc
import openmc.stats
from openmc.stats.univariate import _INTERPOLATION_SCHEMES
from scipy.integrate import trapezoid
from tests.unit_tests import assert_sample_mean
@ -273,7 +274,7 @@ def test_watt():
@pytest.mark.flaky(reruns=1)
def test_tabular():
# test linear-linear sampling
x = np.array([0.0, 5.0, 7.0, 10.0])
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
@ -281,9 +282,12 @@ def test_tabular():
assert_sample_mean(samples, d.mean())
assert np.all(weights == 1.0)
# test linear-linear normalization
d.normalize()
assert d.integral() == pytest.approx(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')
@ -291,6 +295,12 @@ def test_tabular():
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)