Skip to content

Commit 8198e60

Browse files
authored
Add truncated normal distribution support (#3761)
1 parent a3426cf commit 8198e60

8 files changed

Lines changed: 385 additions & 28 deletions

File tree

examples/parameterized_custom_source/parameterized_source_ring.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
#include <cmath> // for M_PI
2-
#include <memory> // for unique_ptr
1+
#include <cmath>
2+
#include <memory>
33
#include <unordered_map>
44

55
#include "openmc/particle.h"

include/openmc/distribution.h

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -265,23 +265,29 @@ class Watt : public Distribution {
265265
};
266266

267267
//==============================================================================
268-
//! Normal distributions with form 1/2*std_dev*sqrt(pi) exp
269-
//! (-(e-E0)/2*std_dev)^2
268+
//! Normal distribution with optional truncation bounds.
269+
//!
270+
//! The standard normal PDF is 1/(sqrt(2*pi)*sigma) *
271+
//! exp(-(x-mu)^2/(2*sigma^2)). When truncated to [lower, upper], the PDF is
272+
//! renormalized so that it integrates to 1 over the truncation interval.
270273
//==============================================================================
271274

272275
class Normal : public Distribution {
273276
public:
274277
explicit Normal(pugi::xml_node node);
275-
Normal(double mean_value, double std_dev)
276-
: mean_value_ {mean_value}, std_dev_ {std_dev} {};
278+
Normal(double mean_value, double std_dev, double lower = -INFTY,
279+
double upper = INFTY);
277280

278281
//! Evaluate probability density, f(x), at a point
279282
//! \param x Point to evaluate f(x)
280-
//! \return f(x)
283+
//! \return f(x), accounting for truncation normalization
281284
double evaluate(double x) const override;
282285

283286
double mean_value() const { return mean_value_; }
284287
double std_dev() const { return std_dev_; }
288+
double lower() const { return lower_; }
289+
double upper() const { return upper_; }
290+
bool is_truncated() const { return is_truncated_; }
285291

286292
protected:
287293
//! Sample a value (unbiased) from the distribution
@@ -290,8 +296,15 @@ class Normal : public Distribution {
290296
double sample_unbiased(uint64_t* seed) const override;
291297

292298
private:
293-
double mean_value_; //!< middle of distribution [eV]
294-
double std_dev_; //!< standard deviation [eV]
299+
double mean_value_; //!< Mean of distribution
300+
double std_dev_; //!< Standard deviation
301+
double lower_; //!< Lower truncation bound (default: -INFTY)
302+
double upper_; //!< Upper truncation bound (default: +INFTY)
303+
bool is_truncated_; //!< True if bounds are finite
304+
double norm_factor_; //!< Normalization factor for truncated distribution
305+
306+
//! Compute normalization factor for truncated distribution
307+
void compute_normalization();
295308
};
296309

297310
//==============================================================================

include/openmc/math_functions.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,5 +223,15 @@ double log1prel(double x);
223223
void get_energy_index(
224224
const vector<double>& energies, double E, int& i, double& f);
225225

226+
//==============================================================================
227+
//! Calculate the cumulative distribution function of the standard normal
228+
//! distribution at a given value.
229+
//!
230+
//! \param z The value at which to evaluate the CDF
231+
//! \return Phi(z) = P(X <= z) for X ~ N(0,1)
232+
//==============================================================================
233+
234+
double standard_normal_cdf(double z);
235+
226236
} // namespace openmc
227237
#endif // OPENMC_MATH_FUNCTIONS_H

openmc/stats/univariate.py

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,18 +1049,27 @@ def from_xml_element(cls, elem: ET.Element):
10491049

10501050

10511051
class Normal(Univariate):
1052-
r"""Normally distributed sampling.
1052+
r"""Normally distributed sampling with optional truncation.
10531053
1054-
The Normal Distribution is characterized by two parameters
1055-
:math:`\mu` and :math:`\sigma` and has density function
1056-
:math:`p(X) dX = 1/(\sqrt{2\pi}\sigma) e^{-(X-\mu)^2/(2\sigma^2)}`
1054+
The normal distribution is characterized by parameters :math:`\mu` and
1055+
:math:`\sigma` and has density function :math:`p(X) = 1/(\sqrt{2\pi}\sigma)
1056+
e^{-(X-\mu)^2/(2\sigma^2)}`. When truncated to the interval [lower, upper],
1057+
the distribution is renormalized so that the PDF integrates to 1 over the
1058+
truncation interval.
1059+
1060+
.. versionchanged:: 0.15.4
1061+
Added optional truncation bounds via `lower` and `upper` parameters.
10571062
10581063
Parameters
10591064
----------
10601065
mean_value : float
1061-
Mean value of the distribution
1066+
Mean value of the distribution
10621067
std_dev : float
10631068
Standard deviation of the Normal distribution
1069+
lower : float, optional
1070+
Lower truncation bound. Defaults to -infinity (no lower bound).
1071+
upper : float, optional
1072+
Upper truncation bound. Defaults to +infinity (no upper bound).
10641073
bias : openmc.stats.Univariate, optional
10651074
Distribution for biased sampling.
10661075
@@ -1070,19 +1079,29 @@ class Normal(Univariate):
10701079
Mean of the Normal distribution
10711080
std_dev : float
10721081
Standard deviation of the Normal distribution
1082+
lower : float
1083+
Lower truncation bound
1084+
upper : float
1085+
Upper truncation bound
10731086
support : tuple of float
10741087
A 2-tuple (lower, upper) defining the interval over which the
10751088
distribution is nonzero-valued
10761089
bias : openmc.stats.Univariate or None
10771090
Distribution for biased sampling
10781091
"""
10791092

1080-
def __init__(self, mean_value, std_dev, bias: Univariate | None = None):
1093+
def __init__(self, mean_value, std_dev, lower=-np.inf, upper=np.inf,
1094+
bias: Univariate | None = None):
10811095
self.mean_value = mean_value
10821096
self.std_dev = std_dev
1097+
self.lower = lower
1098+
self.upper = upper
1099+
self._compute_normalization()
10831100
super().__init__(bias)
10841101

10851102
def __len__(self):
1103+
if self._is_truncated:
1104+
return 4
10861105
return 2
10871106

10881107
@property
@@ -1104,16 +1123,69 @@ def std_dev(self, std_dev):
11041123
cv.check_greater_than('Normal std_dev', std_dev, 0.0)
11051124
self._std_dev = std_dev
11061125

1126+
@property
1127+
def lower(self):
1128+
return self._lower
1129+
1130+
@lower.setter
1131+
def lower(self, lower):
1132+
cv.check_type('Normal lower bound', lower, Real)
1133+
self._lower = lower
1134+
1135+
@property
1136+
def upper(self):
1137+
return self._upper
1138+
1139+
@upper.setter
1140+
def upper(self, upper):
1141+
cv.check_type('Normal upper bound', upper, Real)
1142+
self._upper = upper
1143+
1144+
def _compute_normalization(self):
1145+
"""Compute normalization factor for truncated distribution."""
1146+
# Check if truncation bounds are finite
1147+
self._is_truncated = (self._lower > -np.inf or self._upper < np.inf)
1148+
1149+
if self._lower >= self._upper:
1150+
raise ValueError("Normal distribution lower bound must be less "
1151+
"than upper bound.")
1152+
1153+
if self._is_truncated:
1154+
alpha = (self._lower - self._mean_value) / self._std_dev
1155+
beta = (self._upper - self._mean_value) / self._std_dev
1156+
cdf_diff = scipy.stats.norm.cdf(beta) - scipy.stats.norm.cdf(alpha)
1157+
if cdf_diff <= 0:
1158+
raise ValueError("Truncation bounds exclude entire distribution")
1159+
self._norm_factor = 1.0 / cdf_diff
1160+
else:
1161+
self._norm_factor = 1.0
1162+
11071163
@property
11081164
def support(self):
1109-
return (-np.inf, np.inf)
1165+
return (self._lower, self._upper)
11101166

11111167
def _sample_unbiased(self, n_samples=1, seed=None):
11121168
rng = np.random.RandomState(seed)
1113-
return rng.normal(self.mean_value, self.std_dev, n_samples)
1169+
if not self._is_truncated:
1170+
return rng.normal(self.mean_value, self.std_dev, n_samples)
1171+
else:
1172+
# Use scipy's truncated normal for efficient direct sampling
1173+
a = (self._lower - self._mean_value) / self._std_dev
1174+
b = (self._upper - self._mean_value) / self._std_dev
1175+
return scipy.stats.truncnorm.rvs(
1176+
a, b, loc=self._mean_value, scale=self._std_dev,
1177+
size=n_samples, random_state=rng
1178+
)
11141179

11151180
def evaluate(self, x):
1116-
return scipy.stats.norm.pdf(x, self.mean_value, self.std_dev)
1181+
"""Evaluate PDF at x, returning normalized value for truncated dist."""
1182+
x = np.asarray(x)
1183+
f = scipy.stats.norm.pdf(x, self.mean_value, self.std_dev)
1184+
if self._is_truncated:
1185+
# PDF is zero outside bounds
1186+
in_bounds = (x >= self._lower) & (x <= self._upper)
1187+
f = np.where(in_bounds, f * self._norm_factor, 0.0)
1188+
return f
11171189

11181190
def to_xml_element(self, element_name: str):
11191191
"""Return XML representation of the Normal distribution
@@ -1126,12 +1198,16 @@ def to_xml_element(self, element_name: str):
11261198
Returns
11271199
-------
11281200
element : lxml.etree._Element
1129-
XML element containing Watt distribution data
1201+
XML element containing Normal distribution data
11301202
11311203
"""
11321204
element = ET.Element(element_name)
11331205
element.set("type", "normal")
1134-
element.set("parameters", f'{self.mean_value} {self.std_dev}')
1206+
if self._is_truncated:
1207+
element.set("parameters",
1208+
f'{self.mean_value} {self.std_dev} {self.lower} {self.upper}')
1209+
else:
1210+
element.set("parameters", f'{self.mean_value} {self.std_dev}')
11351211
self._append_bias_to_xml(element)
11361212
return element
11371213

@@ -1152,7 +1228,10 @@ def from_xml_element(cls, elem: ET.Element):
11521228
"""
11531229
params = get_elem_list(elem, "parameters", float)
11541230
bias_dist = cls._read_bias_from_xml(elem)
1155-
return cls(*map(float, params), bias=bias_dist)
1231+
if len(params) == 4:
1232+
return cls(params[0], params[1], params[2], params[3], bias=bias_dist)
1233+
else:
1234+
return cls(params[0], params[1], bias=bias_dist)
11561235

11571236

11581237
def muir(e0: float, m_rat: float, kt: float, bias: Univariate | None = None):
@@ -1184,7 +1263,7 @@ def muir(e0: float, m_rat: float, kt: float, bias: Univariate | None = None):
11841263
"""
11851264
# https://permalink.lanl.gov/object/tr?what=info:lanl-repo/lareport/LA-05411-MS
11861265
std_dev = sqrt(2 * e0 * kt / m_rat)
1187-
return Normal(e0, std_dev, bias)
1266+
return Normal(e0, std_dev, bias=bias)
11881267

11891268

11901269
# Retain deprecated name for the time being

src/distribution.cpp

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -363,30 +363,92 @@ double Watt::evaluate(double x) const
363363
//==============================================================================
364364
// Normal implementation
365365
//==============================================================================
366+
367+
Normal::Normal(double mean_value, double std_dev, double lower, double upper)
368+
: mean_value_ {mean_value}, std_dev_ {std_dev}, lower_ {lower}, upper_ {upper}
369+
{
370+
compute_normalization();
371+
}
372+
366373
Normal::Normal(pugi::xml_node node)
367374
{
368375
auto params = get_node_array<double>(node, "parameters");
369-
if (params.size() != 2) {
376+
if (params.size() != 2 && params.size() != 4) {
370377
openmc::fatal_error("Normal energy distribution must have two "
371-
"parameters specified.");
378+
"parameters (mean, std_dev) or four parameters "
379+
"(mean, std_dev, lower, upper) specified.");
372380
}
373381

374382
mean_value_ = params.at(0);
375383
std_dev_ = params.at(1);
376384

385+
// Optional truncation bounds
386+
if (params.size() == 4) {
387+
lower_ = params.at(2);
388+
upper_ = params.at(3);
389+
} else {
390+
lower_ = -INFTY;
391+
upper_ = INFTY;
392+
}
393+
394+
compute_normalization();
377395
read_bias_from_xml(node);
378396
}
379397

398+
void Normal::compute_normalization()
399+
{
400+
// Validate bounds
401+
if (lower_ >= upper_) {
402+
openmc::fatal_error(
403+
"Normal distribution lower bound must be less than upper bound.");
404+
}
405+
406+
// Check if truncation bounds are finite
407+
is_truncated_ = (lower_ > -INFTY || upper_ < INFTY);
408+
409+
if (is_truncated_) {
410+
double alpha = (lower_ - mean_value_) / std_dev_;
411+
double beta = (upper_ - mean_value_) / std_dev_;
412+
double cdf_diff = standard_normal_cdf(beta) - standard_normal_cdf(alpha);
413+
414+
if (cdf_diff <= 0.0) {
415+
openmc::fatal_error(
416+
"Normal distribution truncation bounds exclude entire distribution.");
417+
}
418+
norm_factor_ = 1.0 / cdf_diff;
419+
} else {
420+
norm_factor_ = 1.0;
421+
}
422+
}
423+
380424
double Normal::sample_unbiased(uint64_t* seed) const
381425
{
382-
return normal_variate(mean_value_, std_dev_, seed);
426+
if (!is_truncated_) {
427+
return normal_variate(mean_value_, std_dev_, seed);
428+
}
429+
430+
// Rejection sampling for truncated normal
431+
double x;
432+
do {
433+
x = normal_variate(mean_value_, std_dev_, seed);
434+
} while (x < lower_ || x > upper_);
435+
return x;
383436
}
384437

385438
double Normal::evaluate(double x) const
386439
{
387-
return (1.0 / (std::sqrt(2.0 / PI) * std_dev_)) *
388-
std::exp(-(std::pow((x - mean_value_), 2.0)) /
389-
(2.0 * std::pow(std_dev_, 2.0)));
440+
// Return 0 outside truncation bounds
441+
if (x < lower_ || x > upper_) {
442+
return 0.0;
443+
}
444+
445+
// Standard normal PDF value
446+
double pdf = (1.0 / (std::sqrt(2.0 * PI) * std_dev_)) *
447+
std::exp(-std::pow((x - mean_value_), 2.0) /
448+
(2.0 * std::pow(std_dev_, 2.0)));
449+
450+
// Apply normalization for truncation
451+
return pdf * norm_factor_;
390452
}
391453

392454
//==============================================================================

src/math_functions.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ double t_percentile(double p, int df)
9595
return t;
9696
}
9797

98+
double standard_normal_cdf(double z)
99+
{
100+
// Use the complementary error function to compute the standard normal CDF
101+
// Phi(z) = 0.5 * (1 + erf(z / sqrt(2))) = 0.5 * erfc(-z / sqrt(2))
102+
return 0.5 * std::erfc(-z / std::sqrt(2.0));
103+
}
104+
98105
void calc_pn_c(int n, double x, double pnx[])
99106
{
100107
pnx[0] = 1.;

0 commit comments

Comments
 (0)