5.3.5. Adjoint Method with Multiple Detector Responses

In this example, we will explore leveraging OpenSn’s adjoint formulation of the linear Boltzmann solver to compute response functions in two Helium-3 detectors within a mockup 2D glovebox.

The following is a complete transport simulation example. Each element of the simulation can be described in the sections below:


5.3.5.1. Prerequisites

Before running this example, make sure that the Python module of OpenSn was installed.

5.3.5.1.1. Converting and Running this Notebook from the Terminal

To run this notebook from the terminal, simply type:

jupyter nbconvert --to python --execute glovebox_adj.ipynb.

To run this notebook in parallel (for example, using 4 processes), simply type:

mpiexec -n 4 jupyter nbconvert --to python --execute glovebox_adj.ipynb.

[ ]:
from mpi4py import MPI
size = MPI.COMM_WORLD.size
rank = MPI.COMM_WORLD.rank
barrier = MPI.COMM_WORLD.barrier

if rank == 0:
    print(f"Running the LBS adjoint glovebox example problem with {size} MPI processors.")

5.3.5.1.2. Import Requirements

Import required classes and functions from the Python interface of OpenSn. Make sure that the path to PyOpenSn is appended to Python’s PATH.

[ ]:
import os
import sys
import math
import numpy as np

# assuming that the execute dir is the notebook dir
# this line is not necessary when PyOpenSn is installed using pip
sys.path.append("../../..")

from pyopensn.mesh import FromFileMeshGenerator, PETScGraphPartitioner
from pyopensn.xs import MultiGroupXS
from pyopensn.source import VolumetricSource
from pyopensn.aquad import GLCProductQuadrature2DXY
from pyopensn.solver import DiscreteOrdinatesProblem, SteadyStateSourceSolver
from pyopensn.response import ResponseEvaluator
from pyopensn.math import Vector3, VectorSpatialFunction
from pyopensn.fieldfunc import FieldFunctionInterpolationVolume, \
                               FieldFunctionInterpolationLine, \
                               FieldFunctionGridBased
from pyopensn.logvol import RCCLogicalVolume
from pyopensn.context import UseColor, Finalize

5.3.5.2. Geometry

In this example, we define a 2D multigroup, source-driven detector response problem. The neutron source is modeled as a circular region of Cf-252 encased in 0.1 cm of stainless steel (SS-316). Two He-3 detectors are then defined as circular regions of He-3 within SS-316 sleeves, each embedded in a \(10\text{cm}\times 10\text{cm}\) block of high-density polyethylene (HDPE). The geometric layout of the problem is shown below:

c338cf75425b48b191cc5fb7fbf6a6ae


5.3.5.3. Mesh

In this section, we import an unstructured mesh generated using the open-source meshing tool Gmsh.

[ ]:
meshgen = FromFileMeshGenerator(
    filename="glovebox.msh",
    partitioner=PETScGraphPartitioner(type='parmetis'),
)
grid = meshgen.Execute()

9e55b7343df04f6ead770ce498b443f6

5debb86aecc14b80aa3ed11f6599101b

b24b36706f0e45fe82c7b16dc6d3892c

The total cell count for this mesh is 8,214.

5.3.5.3.1. Cross Sections

In this problem we are running a multigroup problem for the absoprtion reaction rate in a He-3 glovebox. This begins with importing our multigroup cross sections. In this case we will be using the WIMS69 group structure for 5 materials:

  • Air

  • SS-316

  • HDPE

  • He-3

  • Cf-252

Since we are importing a Gmsh mesh, materials IDs are assigned to the corresponding physcial group ID. Look at glovebox.msh under PhysicalNames.

[ ]:
xs_dir = "WIMS69"
xs_air = MultiGroupXS()
xs_air.LoadFromOpenSn(xs_dir+"/Air.cxs")

xs_steel = MultiGroupXS()
xs_steel.LoadFromOpenSn(xs_dir+"/SS_316.cxs")

xs_hdpe = MultiGroupXS()
xs_hdpe.LoadFromOpenSn(xs_dir+"/HDPE.cxs")

xs_he3 = MultiGroupXS()
xs_he3.LoadFromOpenSn(xs_dir+"/He3.cxs")

xs_cf = MultiGroupXS()
xs_cf.LoadFromOpenSn(xs_dir+"/Cf252.cxs")

xsecs = [{"block_ids": [1], "xs": xs_air},
         {"block_ids": [2], "xs": xs_steel},
         {"block_ids": [3], "xs": xs_hdpe},
         {"block_ids": [4], "xs": xs_he3},
         {"block_ids": [5], "xs": xs_cf}]

5.3.5.4. Solver

5.3.5.4.1. Angular Quadrature

Since we are solving a 2D problem we will create Gauss-Legendre Product Quadrature in 2D. In this case we will use 2 polar angles and 128 azimuthal angles. With polar symmetry that leaves us with 128 angles.

[ ]:
pquad = GLCProductQuadrature2DXY(n_polar=2,
                                 n_azimuthal=128,
                                 scattering_order=0)

5.3.5.4.2. Group Structure

Since we are using the WIMS69 group structure, we define a groupset block with 69 energy groups.

[ ]:
num_groups = 69
grpsets = [
    {
        "groups_from_to": (0, num_groups-1),
        "angular_quadrature": pquad,
        "inner_linear_method": "petsc_gmres",
        "l_abs_tol": 1.0e-9,
        "l_max_its": 500,
        "gmres_restart_interval": 100,
    },
]

5.3.5.4.3. Adjoint Source Definition

To solve the 2D transport problem using the adjoint operator, we first define the adjoint source \(q^{\dagger}\) over the detector volume, assigning a source strength equal to the absorption cross section of the detector, \(\sigma_a^{\text{He3},g}\). By default, adjoint sources are distributed isotropically in angle across the source domain.

To implement this, we create a ResponseFunction. A response function allows us to define the group-wise source strength, which can be used to specify responses in particular energy bins or, in this case, to capture the detector response. To record the response for a specific detector, we set the logical_volume of the adjoint source to the detector of interest.

[ ]:
det1 = [7.0, 10.0, 0.0]
det2 = [47.0, 10.0, 0.0]
dets = [det1, det2]
det_logvols = []
for det in dets:
    r_he3 = 1.2
    he3 = RCCLogicalVolume(
                r=r_he3,
                x0=det[0], y0=det[1],
                z0=-2.0, vz= 2.0
    )
    det_logvols.append(he3)

For this initial example, we will be computing the detector response for detector-1, towards the left of the detector.

[ ]:
def ResponseFunction(xyz, mat_id):
    response = xs_he3.sigma_a
    return response
response_func = VectorSpatialFunction(ResponseFunction)

adj_src = VolumetricSource(logical_volume=det_logvols[0], func=response_func)

5.3.5.4.4. Discrete Ordinates Problem

For the problem block, we provide

  • mesh : The mesh

  • num_groups : The number of energy groups

  • groupsets : The groupsets block

  • scattering_order : The scattering order

  • xs_map : Cross section map

  • volumetric_sources : The volumetric source

  • boundary_conditions : The boundary conditions

  • options : Physics solver options

[ ]:
phys = DiscreteOrdinatesProblem(
    mesh=grid,
    num_groups=num_groups,
    groupsets=grpsets,
    scattering_order=0,
    xs_map=xsecs,
    volumetric_sources = [adj_src],
    boundary_conditions = [
        {"name": "xmin", "type": "vacuum"},
        {"name": "xmax", "type": "vacuum"},
        {"name": "ymin", "type": "vacuum"},
        {"name": "ymax", "type": "vacuum"},
        {"name": "zmin", "type": "reflecting"},
        {"name": "zmax", "type": "reflecting"}
    ],
    options = {"save_angular_flux": True,
               "adjoint": True},
)

5.3.5.4.5. Execute

We then create the physics solver, initialize it, and execute it.

[ ]:
ss_solver = SteadyStateSourceSolver(problem=phys)
ss_solver.Initialize()
ss_solver.Execute()

5.3.5.5. Post Processing

5.3.5.5.1. Write Flux Moments

When evaluating response functions via the adjoint formalism its efficiency derives from having access to the adjoint solution \(\phi^{\dagger,g}\). For this, we export the adjoint flux moments to a .h5 file using WriteFluxMoments. The adjoint data is then saved to a location of our choosing.

[ ]:
data_dir = os.getcwd()+"/Data"
os.makedirs(data_dir, exist_ok=True)

phys.WriteFluxMoments(data_dir+"/adjphi_det1_p")

5.3.5.5.2. Detector Response

An adjoint formulation has no meaning without a corresponding forward response. For the volumetric source, we first define the forward source spectrum. In this case, we assign a uniform energy spectrum distributed throughout the source logical volume. Using VolumetricSource, this group-wise source is specified per unit length for material ID 5.

[ ]:
groups = np.flip([1.00000E-11, 5.00000E-09, 1.00000E-08, 1.50000E-08, 2.00000E-08,
                  2.50000E-08, 3.00000E-08, 3.50000E-08, 4.20000E-08, 5.00000E-08,
                  5.80000E-08, 6.70000E-08, 8.00000E-08, 1.00000E-07, 1.40000E-07,
                  1.80000E-07, 2.20000E-07, 2.50000E-07, 2.80000E-07, 3.00000E-07,
                  3.20000E-07, 3.50000E-07, 4.00000E-07, 5.00000E-07, 6.25000E-07,
                  7.80000E-07, 8.50000E-07, 9.10000E-07, 9.50000E-07, 9.72000E-07,
                  9.96000E-07, 1.02000E-06, 1.04500E-06, 1.07100E-06, 1.09700E-06,
                  1.12300E-06, 1.15000E-06, 1.30000E-06, 1.50000E-06, 2.10000E-06,
                  2.60000E-06, 3.30000E-06, 4.00000E-06, 9.87700E-06, 1.59680E-05,
                  2.77000E-05, 4.80520E-05, 7.55014E-05, 1.48729E-04, 3.67263E-04,
                  9.06899E-04, 1.42510E-03, 2.23945E-03, 3.51910E-03, 5.53000E-03,
                  9.11800E-03, 1.50300E-02, 2.47800E-02, 4.08500E-02, 6.73400E-02,
                  1.11000E-01, 1.83000E-01, 3.02500E-01, 5.00000E-01, 8.21000E-01,
                  1.35300E+00, 2.23100E+00, 3.67900E+00, 6.06550E+00, 1.00000E+01])
group_width = -np.diff(groups)
dE = groups[0] - groups[-1]
dA = np.pi*0.2*0.2
Q = (group_width / dE / dA).tolist()

fwd_src = {'material': [{'block_id': 5, 'strength': Q}]}

With the adjoint data in hand and a forward source defined, we now create a ResponseEvaluator.

We supply two options to the evaluator; the buffers (adjoint data) and the sources.

[ ]:
evaluator = ResponseEvaluator(problem = phys)
evaluator.SetOptions(
    buffers = [{
        'name': 'detector1',
        'file_prefixes': {'flux_moments': data_dir+'/adjphi_det1_p'}
    }],
    sources = fwd_src
)

For computing the detector response we make use of EvaluateResponse which given a buffer name will compute an inner product of the adjoint flux \(\phi^{\dagger}\) against the forward source \(q\) using the duality principle such that:

\[\text{QoI} = \sum^G_g \, \text{RR}^g = \sum^G_g \, \int_\mathcal{D} d^3r \, \int_{(4\pi)} d\Omega \, q^g(\vec{r},\vec\Omega) \psi^{\dagger,g}(\vec{r},\vec\Omega) = \sum^G_g \, \int_\mathcal{D} d^3r \, q^g(\vec{r}) \phi^{\dagger,g}(\vec{r})\]

At each group the solution \(\phi^g(\vec{r})\) is multiplied by the detector response at that group \(\sigma^{He3,g}_{a}\).

[ ]:
adj_resp = evaluator.EvaluateResponse("detector1")

if rank == 0:
    print(f"{'Total Response Detector 1:'} {adj_resp:.6e}")
Total Response Detector 1 : 4.457174e-03
Total Response Detector 2 : 4.458115e-03

With our field function defined, we can also export the multi-group adjoint flux moments, \(\phi^{\dagger,g}\), to a .vtu file using ExportMultipleToPVTU.

[ ]:
fflist = phys.GetScalarFieldFunctionList(only_scalar_flux=False)
fields = []
for g in range(num_groups):
    fields.append(fflist[g][0])

FFGrid = FieldFunctionGridBased
FFGrid.ExportMultipleToPVTU(fields, "AdjFlux/detector1_p")

Detector 1:

[1.353, 2.231] MeV

e91f7c9bc79e447db1294ffcf5505082

[2.2e-07, 2.5e-07] MeV

a9677f68186044f7b877643f6dd8319a

Detector 2:

[1.353, 2.231] MeV

fdd16c4375a14cb9a1b5826bc7fd15d3

[2.2e-07, 2.5e-07] MeV

d9ce5e31aebf42db9bb9430ed5d65049

5.3.5.6. Finalize (for Jupyter Notebook only)

In Python script mode, PyOpenSn automatically handles environment termination. However, this automatic finalization does not occur when running in a Jupyter notebook, so explicit finalization of the environment at the end of the notebook is required. Do not call the finalization in Python script mode, or in console mode.

Note that PyOpenSn’s finalization must be called before MPI’s finalization.

[ ]:
from IPython import get_ipython

def finalize_env():
    Finalize()
    MPI.Finalize()

ipython_instance = get_ipython()
if ipython_instance is not None:
    ipython_instance.events.register("post_execute", finalize_env)

os.system("rm -rf Data AdjFlux Results")