Strengthen tokamak source validation and documentation

This commit is contained in:
Paul Romano 2026-07-14 19:05:40 -05:00
parent c29700a80b
commit c0471735ac
6 changed files with 67 additions and 9 deletions

View file

@ -1044,8 +1044,10 @@ attributes/sub-elements:
:emission_density:
A list of neutron emission densities :math:`S(r)` evaluated at each
``r_over_a`` grid point (arbitrary units, must be non-negative). Only the
shape matters, since the profile is normalized internally. Must have the
same length as ``r_over_a``.
shape matters, since the profile is normalized internally. Values are
interpolated linearly between grid points and the profile is refined on an
internal grid for radial sampling. Must have the same length as
``r_over_a`` and contain at least one positive value.
:phi_start:
The starting toroidal angle in [rad].
@ -1060,7 +1062,8 @@ attributes/sub-elements:
:n_alpha:
The number of poloidal-angle grid points used to build the sampling CDFs
(must be > 2). Larger values reduce discretization bias.
(must be > 2). Larger values reduce discretization bias; values below 51
produce a warning.
*Default*: 101

View file

@ -306,7 +306,9 @@ The plasma shape is described by the major radius :math:`R_0`, minor radius
Shafranov shift :math:`\Delta`. The neutron birth profile is given as an
emission density :math:`S(r/a)` tabulated on a normalized minor-radius grid that
runs from 0 (magnetic axis) to 1 (last closed flux surface); only the shape of
the profile matters, since it is normalized internally. For example::
the profile matters, since it is normalized internally. The emission density
is linearly interpolated between the supplied points and refined internally for
radial sampling. For example::
import numpy as np

View file

@ -46,7 +46,7 @@ class SourceBase(ABC):
Attributes
----------
type : {'independent', 'file', 'compiled', 'mesh'}
type : {'independent', 'file', 'compiled', 'mesh', 'tokamak'}
Indicator of source type.
strength : float
Strength of the source
@ -931,7 +931,9 @@ class TokamakSource(SourceBase):
Normalized minor radius grid points, must start at 0 and end at 1
emission_density : numpy.ndarray
Emission density S(r) at each r/a point (arbitrary units, must be >= 0).
Must have the same length as ``r_over_a``.
Values are linearly interpolated between grid points and refined on an
internal grid for radial sampling. Must have the same length as
``r_over_a`` and contain at least one positive value.
energy : openmc.stats.Univariate or Sequence[openmc.stats.Univariate]
Energy distribution(s). Either a single distribution used at all radii,
or one distribution per ``r_over_a`` grid point. When one distribution
@ -948,7 +950,9 @@ class TokamakSource(SourceBase):
phi_extent : float
Toroidal angle extent in [rad] (default: 2π)
n_alpha : int
Number of poloidal angle grid points for CDF sampling (default: 101)
Number of poloidal angle grid points for CDF sampling (default: 101).
Values below 51 produce a warning because they may introduce noticeable
discretization bias.
vertical_shift : float
Vertical shift of the plasma center in [cm] (default: 0)
strength : float
@ -1027,8 +1031,10 @@ class TokamakSource(SourceBase):
self.energy = energy
self.time = time
# Cross-field consistency checks (each setter only validates its own
# field, so the relationships between fields are verified here)
self._validate()
def _validate(self):
"""Validate relationships between tokamak source parameters."""
if self.minor_radius >= self.major_radius:
raise ValueError(
f"minor_radius ({self.minor_radius}) must be smaller than "
@ -1041,6 +1047,8 @@ class TokamakSource(SourceBase):
raise ValueError(
f"emission_density (length {len(self.emission_density)}) must "
f"have the same length as r_over_a (length {len(self.r_over_a)})")
if not np.any(self.emission_density > 0.0):
raise ValueError("emission_density must contain a positive value")
if len(self.energy) not in (1, len(self.r_over_a)):
raise ValueError(
f"Number of energy distributions ({len(self.energy)}) must be "
@ -1182,6 +1190,10 @@ class TokamakSource(SourceBase):
def n_alpha(self, value: int):
cv.check_type('n_alpha', value, Integral)
cv.check_greater_than('n_alpha', value, 2)
if value < 51:
warnings.warn(
"n_alpha values below 51 may introduce noticeable "
"discretization bias in tokamak source sampling", stacklevel=2)
self._n_alpha = value
@property
@ -1202,6 +1214,8 @@ class TokamakSource(SourceBase):
XML element containing source data
"""
self._validate()
# Geometry parameters
ET.SubElement(element, "major_radius").text = str(self.major_radius)
ET.SubElement(element, "minor_radius").text = str(self.minor_radius)

View file

@ -789,6 +789,10 @@ TokamakSource::TokamakSource(pugi::xml_node node) : Source(node)
if (n_alpha_ <= 2) {
fatal_error("TokamakSource: n_alpha must be > 2.");
}
if (n_alpha_ < 51) {
warning("TokamakSource: n_alpha values below 51 may introduce noticeable "
"discretization bias in source sampling.");
}
if (energy_dists_.empty()) {
fatal_error("TokamakSource: At least one energy distribution is required.");
}

View file

@ -46,6 +46,21 @@ TEST_CASE("Test t_percentile")
}
}
TEST_CASE("Test cylindrical Bessel functions")
{
constexpr double x = 2.0;
constexpr double expected[] {0.22389077914123567, 0.57672480775687339,
0.35283402861563773, 0.12894324947440205};
for (int n = 0; n < 4; ++n) {
REQUIRE_THAT(openmc::cyl_bessel_j(n, x),
Catch::Matchers::WithinRel(expected[n], 1.0e-14));
REQUIRE_THAT(openmc::cyl_bessel_j(n, -x),
Catch::Matchers::WithinRel(
(n % 2 == 0 ? 1.0 : -1.0) * expected[n], 1.0e-14));
}
}
TEST_CASE("Test calc_pn")
{
// The reference solutions come from scipy.special.eval_legendre

View file

@ -85,6 +85,7 @@ def test_tokamak_source_multiple_energies():
(dict(r_over_a=np.linspace(0.1, 1.0, 10)), "must start at 0"),
(dict(r_over_a=np.linspace(0.0, 0.9, 10)), "must end at 1"),
(dict(emission_density=-np.linspace(0.0, 1.0, 10)), "cannot be negative"),
(dict(emission_density=np.zeros(10)), "must contain a positive value"),
])
def test_tokamak_source_invalid(kwargs, match):
with pytest.raises(ValueError, match=match):
@ -102,6 +103,25 @@ def test_tokamak_source_invalid_n_alpha():
make_source(n_alpha=2)
def test_tokamak_source_low_n_alpha_warning():
with pytest.warns(UserWarning, match="below 51"):
make_source(n_alpha=50)
@pytest.mark.parametrize(("attribute", "value", "match"), [
("minor_radius", 700.0, "smaller than major_radius"),
("shafranov_shift", 150.0, "half the minor_radius"),
("emission_density", np.ones(5), "same length as r_over_a"),
("energy", [openmc.stats.Discrete([1.0], [1.0])] * 2,
"Number of energy distributions"),
])
def test_tokamak_source_mutation_validation(attribute, value, match):
src = make_source()
setattr(src, attribute, value)
with pytest.raises(ValueError, match=match):
src.to_xml_element()
@pytest.mark.parametrize(("emission_density", "expected_mean"), [
([1.0, 1.0], 2.0 / 3.0),
([1.0, 0.0], 0.5),