Implement surface flux tallies (#3742)

Co-authored-by: Paul Romano <paul.k.romano@gmail.com>
This commit is contained in:
GuySten 2026-03-04 14:17:48 +02:00 committed by GitHub
parent b796afb591
commit 70be650003
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 424 additions and 54 deletions

View file

@ -1234,6 +1234,23 @@ attributes/sub-elements:
are not eligible to store any particles when using ``cell``, ``cellfrom``
or ``cellto`` attributes. It is recommended to use surface IDs instead.
------------------------------------
``<surface_grazing_cutoff>`` Element
------------------------------------
The ``<surface_grazing_cutoff>`` element specifies the surface flux cosine cutoff.
*Default*: 0.001
-----------------------------------
``<surface_grazing_ratio>`` Element
-----------------------------------
The ``<surface_grazing_ratio>`` element specifies the surface flux cosine
substitution ratio.
*Default*: 0.5
------------------------------
``<survival_biasing>`` Element
------------------------------

View file

@ -205,7 +205,71 @@ had a collision at every event. Thus, for tallies with outgoing-energy filters
or for tallies of scattering moments (which require the scattering cosine of
the change-in-angle), we must use an analog estimator.
.. TODO: Add description of surface current tallies
-----------------------------------
Surface-Integrated Flux and Current
-----------------------------------
Surface tallies allow you to measure particle behavior as they cross specific
boundaries in your geometry. Unlike volume tallies, which integrate over a
volumetric region, surface tallies capture the current or flux passing through a
surface. Surface tallies are estimated using an analog estimator.
Current Score
-------------
When tallying the current across a surface, we simply count the weight of
particles that cross the surface of interest:
.. math::
:label: analog-current-estimator
J = \frac{1}{W} \sum_{i \in S} w_i.
where :math:`J` is the area-integrated current passing through surface
:math:`S`, :math:`W` is the total starting weight of the particles, and
:math:`w_i` is the weight of the particle as it crosses the surface :math:`S`.
Flux Score
----------
When tallying flux over a surface, we use the relationship between current and
flux:
.. math::
:label: surface-flux-estimator
\phi_S = \frac{1}{W} \sum_{i \in S} \frac{w_i}{|\mu|}.
where :math:`\phi_S` is the area-integrated flux over surface :math:`S`,
:math:`W` is the total starting weight of the particles, :math:`w_i` is the
weight of the particle as it crosses the surface :math:`S` and :math:`\mu` is
the cosine of angle between the particle direction and the surface normal.
This equation diverges when the particle crossing the surface is nearly parallel
to it (that is, as :math:`\mu` approaches zero). To remove this divergence,
OpenMC scores:
.. math::
:label: modified-surface-flux-estimator
\phi_S = \frac{1}{W} \sum_{i \in S} w_i f(\mu).
and the function :math:`f` is defined by:
.. math::
f(\mu) = \begin{cases}
\frac{1}{|\mu|} & |\mu| > \mu_\text{cut} \\
\frac{1}{c\mu_\text{cut}} & |\mu| \le \mu_\text{cut}
\end{cases}
where :math:`\mu_\text{cut}` is the grazing cosine cutoff and :math:`c` is the
cosine substitution ratio. The parameters :math:`\mu_\text{cut}` and :math:`c`
can be set by the user via the :attr:`openmc.Settings.surface_grazing_cutoff`
and :attr:`openmc.Settings.surface_grazing_ratio` attributes, respectively. The
default values for these parameters are 0.001 and 0.5 as recommended by
`Favorite, Thomas, and Booth <https://doi.org/10.13182/NSE09-72>`_.
.. _tallies_statistics:

View file

@ -261,18 +261,21 @@ The following tables show all valid scores:
+----------------------+---------------------------------------------------+
|Score | Description |
+======================+===================================================+
|current |Used in combination with a meshsurface filter: |
|current |It may not be used in conjunction with any other |
| |score except flux. |
| | |
| |When used in combination with a meshsurface filter:|
| |Partial currents on the boundaries of each cell in |
| |a mesh. It may not be used in conjunction with any |
| |other score. Only energy and mesh filters may be |
| |used. |
| |Used in combination with a surface filter: |
| |a mesh. |
| | |
| |When used in combination with a surface filter: |
| |Net currents on any surface previously defined in |
| |the geometry. It may be used along with any other |
| |filter, except meshsurface filters. |
| |Surfaces can alternatively be defined with cell |
| |from and cell filters thereby resulting in tallying|
| |partial currents. |
| | |
| |Units are particles per source particle. |
+----------------------+---------------------------------------------------+
|events |Number of scoring events. Units are events per |

View file

@ -181,6 +181,8 @@ extern int64_t ssw_cell_id; //!< Cell id for the surface source
//!< write setting
extern SSWCellType ssw_cell_type; //!< Type of option for the cell
//!< argument of surface source write
extern double surface_grazing_cutoff; //!< surface flux cosine cutoff
extern double surface_grazing_ratio; //!< surface flux substitution ratio
extern TemperatureMethod
temperature_method; //!< method for choosing temperatures
extern double

View file

@ -101,11 +101,19 @@ void score_tracklength_tally(Particle& p, double distance);
//! \param total_distance The distance in [cm] traveled by the particle
void score_timed_tracklength_tally(Particle& p, double total_distance);
//! Score surface or mesh-surface tallies for particle currents.
//! Score mesh-surface tallies for particle currents.
//
//! \param p The particle being tracked
//! \param tallies A vector of the indices of the tallies to score to
void score_surface_tally(Particle& p, const vector<int>& tallies);
void score_meshsurface_tally(Particle& p, const vector<int>& tallies);
//! Score surface tallies for particle currents.
//
//! \param p The particle being tracked
//! \param tallies A vector of the indices of the tallies to score to
//! \param surf The surface being crossed
void score_surface_tally(
Particle& p, const vector<int>& tallies, const Surface& surf);
//! Score the pulse-height tally
//! This is triggered at the end of every particle history

View file

@ -289,6 +289,14 @@ class Settings:
:cellto: Cell ID used to determine if particles crossing identified
surfaces are to be banked. Particles going to this declared
cell will be banked (int)
surface_grazing_cutoff : float
Surface flux cosine cutoff. If not specified, the default value is
0.001. For more information, see the surface tally section in the theory
manual.
surface_grazing_ratio : float
Surface flux cosine substitution ratio. If not specified, the default
value is 0.5. For more information, see the surface tally section in the
theory manual.
survival_biasing : bool
Indicate whether survival biasing is to be used
tabular_legendre : dict
@ -399,6 +407,8 @@ class Settings:
self._uniform_source_sampling = None
self._seed = None
self._stride = None
self._surface_grazing_cutoff = None
self._surface_grazing_ratio = None
self._survival_biasing = None
self._free_gas_threshold = None
@ -710,6 +720,27 @@ class Settings:
cv.check_greater_than('random number generator stride', stride, 0)
self._stride = stride
@property
def surface_grazing_cutoff(self) -> float:
return self._surface_grazing_cutoff
@surface_grazing_cutoff.setter
def surface_grazing_cutoff(self, surface_grazing_cutoff: float):
cv.check_type('surface grazing cutoff', surface_grazing_cutoff, float)
cv.check_greater_than('surface grazing cutoff', surface_grazing_cutoff, 0.0)
cv.check_less_than('surface grazing cutoff', surface_grazing_cutoff, 1.0)
self._surface_grazing_cutoff = surface_grazing_cutoff
@property
def surface_grazing_ratio(self) -> float:
return self._surface_grazing_ratio
@surface_grazing_ratio.setter
def surface_grazing_ratio(self, surface_grazing_ratio: float):
cv.check_type('surface grazing ratio', surface_grazing_ratio, float)
cv.check_greater_than('surface grazing ratio', surface_grazing_ratio, 0.0)
self._surface_grazing_ratio = surface_grazing_ratio
@property
def survival_biasing(self) -> bool:
return self._survival_biasing
@ -1625,6 +1656,16 @@ class Settings:
element = ET.SubElement(root, "stride")
element.text = str(self._stride)
def _create_surface_grazing_cutoff_subelement(self, root):
if self._surface_grazing_cutoff is not None:
element = ET.SubElement(root, "surface_grazing_cutoff")
element.text = str(self._surface_grazing_cutoff)
def _create_surface_grazing_ratio_subelement(self, root):
if self._surface_grazing_ratio is not None:
element = ET.SubElement(root, "surface_grazing_ratio")
element.text = str(self._surface_grazing_ratio)
def _create_survival_biasing_subelement(self, root):
if self._survival_biasing is not None:
element = ET.SubElement(root, "survival_biasing")
@ -2128,6 +2169,16 @@ class Settings:
if text is not None:
self.stride = int(text)
def _surface_grazing_cutoff_from_xml_element(self, root):
text = get_text(root, 'surface_grazing_cutoff')
if text is not None:
self.surface_grazing_cutoff = float(text)
def _surface_grazing_ratio_from_xml_element(self, root):
text = get_text(root, 'surface_grazing_ratio')
if text is not None:
self.surface_grazing_ratio = float(text)
def _survival_biasing_from_xml_element(self, root):
text = get_text(root, 'survival_biasing')
if text is not None:
@ -2435,6 +2486,8 @@ class Settings:
self._create_ptables_subelement(element)
self._create_seed_subelement(element)
self._create_stride_subelement(element)
self._create_surface_grazing_cutoff_subelement(element)
self._create_surface_grazing_ratio_subelement(element)
self._create_survival_biasing_subelement(element)
self._create_cutoff_subelement(element)
self._create_entropy_mesh_subelement(element, mesh_memo)
@ -2549,6 +2602,8 @@ class Settings:
settings._ptables_from_xml_element(elem)
settings._seed_from_xml_element(elem)
settings._stride_from_xml_element(elem)
settings._surface_grazing_cutoff_from_xml_element(elem)
settings._surface_grazing_ratio_from_xml_element(elem)
settings._survival_biasing_from_xml_element(elem)
settings._cutoff_from_xml_element(elem)
settings._entropy_mesh_from_xml_element(elem, meshes)

View file

@ -123,6 +123,8 @@ int openmc_finalize()
settings::restart_run = false;
settings::run_CE = true;
settings::run_mode = RunMode::UNSET;
settings::surface_grazing_cutoff = 0.001;
settings::surface_grazing_ratio = 0.5;
settings::solver_type = SolverType::MONTE_CARLO;
settings::source_latest = false;
settings::source_rejection_fraction = 0.05;

View file

@ -302,6 +302,8 @@ void Particle::event_cross_surface()
surface() = boundary().surface();
n_coord() = boundary().coord_level();
const auto& surf {*model::surfaces[surface_index()].get()};
if (boundary().lattice_translation()[0] != 0 ||
boundary().lattice_translation()[1] != 0 ||
boundary().lattice_translation()[2] != 0) {
@ -312,15 +314,14 @@ void Particle::event_cross_surface()
event() = TallyEvent::LATTICE;
} else {
// Particle crosses surface
const auto& surf {model::surfaces[surface_index()].get()};
// If BC, add particle to surface source before crossing surface
if (surf->surf_source_ && surf->bc_) {
add_surf_source_to_bank(*this, *surf);
if (surf.surf_source_ && surf.bc_) {
add_surf_source_to_bank(*this, surf);
}
this->cross_surface(*surf);
this->cross_surface(surf);
// If no BC, add particle to surface source after crossing surface
if (surf->surf_source_ && !surf->bc_) {
add_surf_source_to_bank(*this, *surf);
if (surf.surf_source_ && !surf.bc_) {
add_surf_source_to_bank(*this, surf);
}
if (settings::weight_window_checkpoint_surface) {
apply_weight_windows(*this);
@ -329,7 +330,7 @@ void Particle::event_cross_surface()
}
// Score cell to cell partial currents
if (!model::active_surface_tallies.empty()) {
score_surface_tally(*this, model::active_surface_tallies);
score_surface_tally(*this, model::active_surface_tallies, surf);
}
}
@ -346,7 +347,7 @@ void Particle::event_collide()
// pre-collision direction to figure out what mesh surfaces were crossed
if (!model::active_meshsurf_tallies.empty())
score_surface_tally(*this, model::active_meshsurf_tallies);
score_meshsurface_tally(*this, model::active_meshsurf_tallies);
// Clear surface component
surface() = SURFACE_NONE;
@ -648,7 +649,7 @@ void Particle::cross_vacuum_bc(const Surface& surf)
// physically moving the particle forward slightly
r() += TINY_BIT * u();
score_surface_tally(*this, model::active_meshsurf_tallies);
score_meshsurface_tally(*this, model::active_meshsurf_tallies);
}
// Score to global leakage tally
@ -680,13 +681,13 @@ void Particle::cross_reflective_bc(const Surface& surf, Direction new_u)
// with a mesh boundary
if (!model::active_surface_tallies.empty()) {
score_surface_tally(*this, model::active_surface_tallies);
score_surface_tally(*this, model::active_surface_tallies, surf);
}
if (!model::active_meshsurf_tallies.empty()) {
Position r {this->r()};
this->r() -= TINY_BIT * u();
score_surface_tally(*this, model::active_meshsurf_tallies);
score_meshsurface_tally(*this, model::active_meshsurf_tallies);
this->r() = r;
}
@ -736,7 +737,7 @@ void Particle::cross_periodic_bc(
if (!model::active_meshsurf_tallies.empty()) {
Position r {this->r()};
this->r() -= TINY_BIT * u();
score_surface_tally(*this, model::active_meshsurf_tallies);
score_meshsurface_tally(*this, model::active_meshsurf_tallies);
this->r() = r;
}

View file

@ -136,6 +136,8 @@ int64_t ssw_max_particles;
int64_t ssw_max_files;
int64_t ssw_cell_id {C_NONE};
SSWCellType ssw_cell_type {SSWCellType::None};
double surface_grazing_cutoff {0.001};
double surface_grazing_ratio {0.5};
TemperatureMethod temperature_method {TemperatureMethod::NEAREST};
double temperature_tolerance {10.0};
double temperature_default {293.6};
@ -678,6 +680,14 @@ void read_settings_xml(pugi::xml_node root)
free_gas_threshold = std::stod(get_node_value(root, "free_gas_threshold"));
}
// Surface grazing
if (check_for_node(root, "surface_grazing_cutoff"))
surface_grazing_cutoff =
std::stod(get_node_value(root, "surface_grazing_cutoff"));
if (check_for_node(root, "surface_grazing_ratio"))
surface_grazing_ratio =
std::stod(get_node_value(root, "surface_grazing_ratio"));
// Survival biasing
if (check_for_node(root, "survival_biasing")) {
survival_biasing = get_node_value_bool(root, "survival_biasing");

View file

@ -52,11 +52,7 @@ void SurfaceFilter::get_all_bins(
auto search = map_.find(p.surface_index());
if (search != map_.end()) {
match.bins_.push_back(search->second);
if (p.surface() < 0) {
match.weights_.push_back(-1.0);
} else {
match.weights_.push_back(1.0);
}
match.weights_.push_back(1.0);
}
}

View file

@ -540,6 +540,8 @@ void Tally::set_scores(const vector<std::string>& scores)
bool legendre_present = false;
bool cell_present = false;
bool cellfrom_present = false;
bool material_present = false;
bool materialfrom_present = false;
bool surface_present = false;
bool meshsurface_present = false;
bool non_cell_energy_present = false;
@ -556,12 +558,21 @@ void Tally::set_scores(const vector<std::string>& scores)
cellfrom_present = true;
} else if (filt->type() == FilterType::CELL) {
cell_present = true;
} else if (filt->type() == FilterType::MATERIALFROM) {
materialfrom_present = true;
} else if (filt->type() == FilterType::MATERIAL) {
material_present = true;
} else if (filt->type() == FilterType::SURFACE) {
surface_present = true;
} else if (filt->type() == FilterType::MESH_SURFACE) {
meshsurface_present = true;
}
}
bool surface_types_present =
(surface_present || cellfrom_present || materialfrom_present);
bool non_meshsurface_types_present =
(surface_present || cell_present || cellfrom_present || material_present ||
materialfrom_present);
// Iterate over the given scores.
for (auto score_str : scores) {
@ -583,6 +594,12 @@ void Tally::set_scores(const vector<std::string>& scores)
fatal_error("Cannot tally flux for an individual nuclide.");
if (energyout_present)
fatal_error("Cannot tally flux with an outgoing energy filter.");
if (surface_types_present) {
if (meshsurface_present)
fatal_error("OpenMC does not support mesh surface fluxes yet");
type_ = TallyType::SURFACE;
estimator_ = TallyEstimator::ANALOG;
}
break;
case SCORE_TOTAL:
@ -615,16 +632,14 @@ void Tally::set_scores(const vector<std::string>& scores)
case SCORE_CURRENT:
// Check which type of current is desired: mesh or surface currents.
if (surface_present || cell_present || cellfrom_present) {
if (meshsurface_present)
if (meshsurface_present) {
if (non_meshsurface_types_present)
fatal_error("Cannot tally mesh surface currents in the same tally as "
"normal surface currents");
type_ = TallyType::SURFACE;
estimator_ = TallyEstimator::ANALOG;
} else if (meshsurface_present) {
type_ = TallyType::MESH_SURFACE;
} else {
fatal_error("Cannot tally currents without surface type filters");
type_ = TallyType::SURFACE;
estimator_ = TallyEstimator::ANALOG;
}
break;
@ -681,15 +696,20 @@ void Tally::set_scores(const vector<std::string>& scores)
"in multi-group mode");
}
// Make sure current scores are not mixed in with volumetric scores.
if (type_ == TallyType::SURFACE || type_ == TallyType::MESH_SURFACE) {
if (scores_.size() != 1)
fatal_error("Cannot tally other scores in the same tally as surface "
"currents.");
// Make sure mesh surface tallies contain only current score.
if (meshsurface_present) {
if ((scores_[0] != SCORE_CURRENT) || (scores_.size() > 1))
fatal_error("Cannot tally score other than 'current' when using a "
"mesh-surface filter.");
}
// Make sure surface tallies contain only surface type scores score.
if (type_ == TallyType::SURFACE) {
for (auto sc : scores_)
if ((sc != SCORE_CURRENT) && (sc != SCORE_FLUX))
fatal_error("Cannot tally scores other than 'current' or 'flux' "
"when using surface filters.");
}
if ((surface_present || meshsurface_present) && scores_[0] != SCORE_CURRENT)
fatal_error("Cannot tally score other than 'current' when using a surface "
"or mesh-surface filter.");
}
void Tally::set_nuclides(pugi::xml_node node)

View file

@ -14,6 +14,7 @@
#include "openmc/settings.h"
#include "openmc/simulation.h"
#include "openmc/string_utils.h"
#include "openmc/surface.h"
#include "openmc/tallies/derivative.h"
#include "openmc/tallies/filter.h"
#include "openmc/tallies/filter_cell.h"
@ -2610,7 +2611,7 @@ void score_collision_tally(Particle& p)
match.bins_present_ = false;
}
void score_surface_tally(Particle& p, const vector<int>& tallies)
void score_meshsurface_tally(Particle& p, const vector<int>& tallies)
{
double current = p.wgt_last();
@ -2654,6 +2655,69 @@ void score_surface_tally(Particle& p, const vector<int>& tallies)
match.bins_present_ = false;
}
void score_surface_tally(
Particle& p, const vector<int>& tallies, const Surface& surf)
{
double wgt = p.wgt_last();
// Sign for net current: +1 if crossing outward (in direction of normal),
// -1 if crossing inward
double current_sign = (p.surface() > 0) ? 1.0 : -1.0;
// Determine absolute cosine of angle between particle direction and surface
// normal, needed for the surface-crossing flux estimator.
auto n = surf.normal(p.r());
n /= n.norm();
double abs_mu = std::min(std::abs(p.u().dot(n)), 1.0);
if (abs_mu < settings::surface_grazing_cutoff)
abs_mu = settings::surface_grazing_ratio * settings::surface_grazing_cutoff;
for (auto i_tally : tallies) {
auto& tally {*model::tallies[i_tally]};
// Initialize an iterator over valid filter bin combinations. If there are
// no valid combinations, use a continue statement to ensure we skip the
// assume_separate break below.
auto filter_iter = FilterBinIter(tally, p);
auto end = FilterBinIter(tally, true, &p.filter_matches());
if (filter_iter == end)
continue;
// Loop over filter bins.
for (; filter_iter != end; ++filter_iter) {
auto filter_index = filter_iter.index_;
auto filter_weight = filter_iter.weight_;
// Loop over scores.
for (auto score_index = 0; score_index < tally.scores_.size();
++score_index) {
auto score_bin = tally.scores_[score_index];
double score;
if (score_bin == SCORE_CURRENT) {
// Net current: weight carries the sign of the crossing direction.
score = wgt * current_sign;
} else {
// SCORE_FLUX: surface-crossing estimator phi_S = sum(w / |mu|).
score = wgt / abs_mu;
}
#pragma omp atomic
tally.results_(filter_index, score_index, TallyResult::VALUE) +=
score * filter_weight;
}
}
// If the user has specified that we can assume all tallies are spatially
// separate, this implies that once a tally has been scored to, we needn't
// check the others. This cuts down on overhead when there are many
// tallies specified
if (settings::assume_separate)
break;
}
// Reset all the filter matches for the next tally event.
for (auto& match : p.filter_matches())
match.bins_present_ = false;
}
void score_pulse_height_tally(Particle& p, const vector<int>& tallies)
{
// The pulse height tally in OpenMC hijacks the logic of CellFilter and

View file

@ -31,7 +31,7 @@
</filter>
<tally id="1">
<filters>1 2</filters>
<scores>current</scores>
<scores>current flux</scores>
</tally>
</tallies>
</model>

View file

@ -5,7 +5,15 @@ tally 1:
0.000000E+00
0.000000E+00
0.000000E+00
0.000000E+00
0.000000E+00
0.000000E+00
0.000000E+00
9.230000E-01
1.791510E-01
3.294304E+00
2.314403E+00
3.869000E+00
3.002523E+00
5.154125E+00
5.314334E+00

View file

@ -1,6 +1,3 @@
import numpy as np
from math import pi
import openmc
import pytest
@ -32,7 +29,7 @@ def model():
mu_filter = openmc.MuSurfaceFilter([-1.0, -0.5, 0.0, 0.5, 1.0])
tally = openmc.Tally()
tally.filters = [surf_filter, mu_filter]
tally.scores = ['current']
tally.scores = ['current', 'flux']
model.tallies.append(tally)
return model

View file

@ -15,14 +15,14 @@ mean,std. dev.
5.5000000e-03,1.0979779e-03
1.2500000e-02,1.5438048e-03
4.4700000e-02,1.9035055e-03
7.3000000e-03,9.8938814e-04
3.6600000e-02,2.5086517e-03
4.1600000e-02,2.4864075e-03
4.2380000e-01,9.6087923e-03
1.0000000e-04,1.0000000e-04
1.5000000e-03,4.5338235e-04
3.0000000e-04,1.5275252e-04
1.7300000e-02,1.4609738e-03
-7.3000000e-03,9.8938814e-04
-3.6600000e-02,2.5086517e-03
-4.1600000e-02,2.4864075e-03
-4.2380000e-01,9.6087923e-03
-1.0000000e-04,1.0000000e-04
-1.5000000e-03,4.5338235e-04
-3.0000000e-04,1.5275252e-04
-1.7300000e-02,1.4609738e-03
-7.3000000e-03,9.8938814e-04
-3.6600000e-02,2.5086517e-03
-4.1600000e-02,2.4864075e-03

View file

@ -169,7 +169,6 @@ class SurfaceTallyTestHarness(PyAPITestHarness):
# Extract the relevant data as a CSV string.
cols = ('mean', 'std. dev.')
return df.to_csv(None, columns=cols, index=False, float_format='%.7e')
return outstr
def test_surface_tally():

View file

@ -21,6 +21,8 @@ def test_export_to_xml(run_in_tmpdir):
s.statepoint = {'batches': [50, 150, 500, 1000]}
s.surf_source_read = {'path': 'surface_source_1.h5'}
s.surf_source_write = {'surface_ids': [2], 'max_particles': 200}
s.surface_grazing_ratio = 0.7
s.surface_grazing_cutoff = 0.1
s.confidence_intervals = True
s.ptables = True
s.plot_seed = 100
@ -107,6 +109,8 @@ def test_export_to_xml(run_in_tmpdir):
assert s.statepoint == {'batches': [50, 150, 500, 1000]}
assert s.surf_source_read['path'].name == 'surface_source_1.h5'
assert s.surf_source_write == {'surface_ids': [2], 'max_particles': 200}
assert s.surface_grazing_ratio == 0.7
assert s.surface_grazing_cutoff == 0.1
assert s.confidence_intervals
assert s.ptables
assert s.plot_seed == 100

View file

@ -0,0 +1,120 @@
"""Tests for surface flux tallying via flux score + SurfaceFilter."""
import math
import pytest
import openmc
@pytest.fixture
def two_cell_model():
"""Simple two-cell slab model with a monodirectional fixed source.
Cell1 occupies x in [-10, 0], cell2 x in [0, 10]. The source fires all
particles from (-5, 0, 0) in the +x direction with weight 1. Every
particle therefore crosses the surface at x=0 from cell1 into cell2 at
mu = 1 (normal incidence).
"""
openmc.reset_auto_ids()
model = openmc.Model()
xmin = openmc.XPlane(-10.0, boundary_type="vacuum")
xmid = openmc.XPlane(0.0)
xmax = openmc.XPlane(10.0, boundary_type="vacuum")
ymin = openmc.YPlane(-10.0, boundary_type="vacuum")
ymax = openmc.YPlane(10.0, boundary_type="vacuum")
zmin = openmc.ZPlane(-10.0, boundary_type="vacuum")
zmax = openmc.ZPlane(10.0, boundary_type="vacuum")
cell1 = openmc.Cell(region=+xmin & -xmid & +ymin & -ymax & +zmin & -zmax)
cell2 = openmc.Cell(region=+xmid & -xmax & +ymin & -ymax & +zmin & -zmax)
model.geometry = openmc.Geometry([cell1, cell2])
src = openmc.IndependentSource()
src.space = openmc.stats.Point((-5.0, 0.0, 0.0))
src.angle = openmc.stats.Monodirectional((1.0, 0.0, 0.0))
model.settings.run_mode = 'fixed source'
model.settings.batches = 1
model.settings.particles = 100
model.settings.source = src
return model, xmid, cell1, cell2
def test_surface_filter_flux_normal_incidence(two_cell_model, run_in_tmpdir):
"""SurfaceFilter + flux at mu=1 gives w/|mu| = 1.0 per source particle."""
model, xmid, *_ = two_cell_model
surf_filter = openmc.SurfaceFilter([xmid])
flux_tally = openmc.Tally()
flux_tally.filters = [surf_filter]
flux_tally.scores = ['flux']
model.tallies = [flux_tally]
model.run(apply_tally_results=True)
flux_mean = flux_tally.mean.flat[0]
# Every particle crosses at mu=1 with weight 1, so flux = 1.0
assert flux_mean == pytest.approx(1.0, rel=1e-8)
def test_surface_filter_current_outward(two_cell_model, run_in_tmpdir):
"""SurfaceFilter + current gives +1.0 for purely outward crossings."""
model, xmid, *_ = two_cell_model
surf_filter = openmc.SurfaceFilter([xmid])
current_tally = openmc.Tally()
current_tally.filters = [surf_filter]
current_tally.scores = ['current']
model.tallies = [current_tally]
model.run(apply_tally_results=True)
current_mean = current_tally.mean.flat[0]
# All crossings are outward → net current = +1.0
assert current_mean == pytest.approx(1.0)
def test_surface_filter_flux_angled(two_cell_model, run_in_tmpdir):
"""Surface flux at 60-degree incidence gives w/|mu| = 2.0."""
model, xmid, *_ = two_cell_model
# Modify source to use 60-degree angle from normal: mu = cos(60°) = 0.5
mu = ux = 0.5
uy = math.sqrt(1.0 - ux**2)
model.settings.source[0].angle = openmc.stats.Monodirectional((ux, uy, 0.0))
surf_filter = openmc.SurfaceFilter([xmid])
flux_tally = openmc.Tally()
flux_tally.filters = [surf_filter]
flux_tally.scores = ['flux']
model.tallies = [flux_tally]
model.run(apply_tally_results=True)
flux_mean = flux_tally.mean.flat[0]
# flux = w/|mu| = 1/0.5 = 2.0
assert flux_mean == pytest.approx(1.0 / mu)
def test_cellfrom_filter_flux_directional(two_cell_model, run_in_tmpdir):
"""SurfaceFilter + CellFromFilter + flux scores only the correct direction."""
model, xmid, cell1, cell2 = two_cell_model
surf_filter = openmc.SurfaceFilter([xmid])
cellfrom_filter = openmc.CellFromFilter([cell1, cell2])
tally = openmc.Tally()
tally.filters = [surf_filter, cellfrom_filter]
tally.scores = ['flux']
model.tallies = [tally]
model.run(apply_tally_results=True)
mean_from1 = tally.mean.flat[0]
mean_from2 = tally.mean.flat[1]
# All particles cross xmid from cell1 at mu=1 → flux = 1.0
assert mean_from1 == pytest.approx(1.0)
# No particles cross xmid from cell2 → flux = 0
assert mean_from2 == pytest.approx(0.0)