@@ -1049,18 +1049,27 @@ def from_xml_element(cls, elem: ET.Element):
10491049
10501050
10511051class 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
11581237def 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
0 commit comments