mirror of
https://github.com/openmc-dev/openmc.git
synced 2026-07-24 03:55:38 -04:00
629 lines
22 KiB
Python
629 lines
22 KiB
Python
"""OpenMC transport operator
|
|
|
|
This module implements a transport operator for OpenMC so that it can be used by
|
|
depletion integrators. The implementation makes use of the Python bindings to
|
|
OpenMC's C API so that reading tally results and updating material number
|
|
densities is all done in-memory instead of through the filesystem.
|
|
|
|
"""
|
|
|
|
import copy
|
|
from collections import OrderedDict
|
|
from itertools import chain
|
|
import os
|
|
import time
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import h5py
|
|
import numpy as np
|
|
|
|
import openmc
|
|
import openmc.capi
|
|
from openmc.data import JOULE_PER_EV
|
|
from . import comm
|
|
from .abc import TransportOperator, OperatorResult
|
|
from .atom_number import AtomNumber
|
|
from .reaction_rates import ReactionRates
|
|
|
|
|
|
def _distribute(items):
|
|
"""Distribute items across MPI communicator
|
|
|
|
Parameters
|
|
----------
|
|
items : list
|
|
List of items of distribute
|
|
|
|
Returns
|
|
-------
|
|
list
|
|
Items assigned to process that called
|
|
|
|
"""
|
|
min_size, extra = divmod(len(items), comm.size)
|
|
j = 0
|
|
for i in range(comm.size):
|
|
chunk_size = min_size + int(i < extra)
|
|
if comm.rank == i:
|
|
return items[j:j + chunk_size]
|
|
j += chunk_size
|
|
|
|
|
|
class Operator(TransportOperator):
|
|
"""OpenMC transport operator for depletion.
|
|
|
|
Instances of this class can be used to perform depletion using OpenMC as the
|
|
transport operator. Normally, a user needn't call methods of this class
|
|
directly. Instead, an instance of this class is passed to an integrator
|
|
function, such as :func:`openmc.deplete.integrator.cecm`.
|
|
|
|
Parameters
|
|
----------
|
|
geometry : openmc.Geometry
|
|
OpenMC geometry object
|
|
settings : openmc.Settings
|
|
OpenMC Settings object
|
|
chain_file : str, optional
|
|
Path to the depletion chain XML file. Defaults to the
|
|
:envvar:`OPENMC_DEPLETE_CHAIN` environment variable if it exists.
|
|
prev_results : ResultsList, optional
|
|
Results from a previous depletion calculation. If this argument is
|
|
specified, the depletion calculation will start from the latest state
|
|
in the previous results.
|
|
|
|
Attributes
|
|
----------
|
|
geometry : openmc.Geometry
|
|
OpenMC geometry object
|
|
settings : openmc.Settings
|
|
OpenMC settings object
|
|
dilute_initial : float
|
|
Initial atom density to add for nuclides that are zero in initial
|
|
condition to ensure they exist in the decay chain. Only done for
|
|
nuclides with reaction rates. Defaults to 1.0e3.
|
|
output_dir : pathlib.Path
|
|
Path to output directory to save results.
|
|
round_number : bool
|
|
Whether or not to round output to OpenMC to 8 digits.
|
|
Useful in testing, as OpenMC is incredibly sensitive to exact values.
|
|
number : openmc.deplete.AtomNumber
|
|
Total number of atoms in simulation.
|
|
nuclides_with_data : set of str
|
|
A set listing all unique nuclides available from cross_sections.xml.
|
|
chain : openmc.deplete.Chain
|
|
The depletion chain information necessary to form matrices and tallies.
|
|
reaction_rates : openmc.deplete.ReactionRates
|
|
Reaction rates from the last operator step.
|
|
burnable_mats : list of str
|
|
All burnable material IDs
|
|
local_mats : list of str
|
|
All burnable material IDs being managed by a single process
|
|
prev_res : ResultsList
|
|
Results from a previous depletion calculation
|
|
|
|
"""
|
|
def __init__(self, geometry, settings, chain_file=None, prev_results=None):
|
|
super().__init__(chain_file)
|
|
self.round_number = False
|
|
self.settings = settings
|
|
self.geometry = geometry
|
|
|
|
if prev_results != None:
|
|
# Reload volumes into geometry
|
|
prev_results[-1].transfer_volumes(geometry)
|
|
|
|
# Store previous results in operator
|
|
self.prev_res = prev_results
|
|
else:
|
|
self.prev_res = None
|
|
|
|
# Clear out OpenMC, create task lists, distribute
|
|
openmc.reset_auto_ids()
|
|
self.burnable_mats, volume, nuclides = self._get_burnable_mats()
|
|
self.local_mats = _distribute(self.burnable_mats)
|
|
|
|
# Determine which nuclides have incident neutron data
|
|
self.nuclides_with_data = self._get_nuclides_with_data()
|
|
|
|
# Select nuclides with data that are also in the chain
|
|
self._burnable_nucs = [nuc.name for nuc in self.chain.nuclides
|
|
if nuc.name in self.nuclides_with_data]
|
|
|
|
# Extract number densities from the geometry / previous depletion run
|
|
self._extract_number(self.local_mats, volume, nuclides, self.prev_res)
|
|
|
|
# Create reaction rates array
|
|
self.reaction_rates = ReactionRates(
|
|
self.local_mats, self._burnable_nucs, self.chain.reactions)
|
|
|
|
|
|
def __call__(self, vec, power, print_out=True):
|
|
"""Runs a simulation.
|
|
|
|
Parameters
|
|
----------
|
|
vec : list of numpy.ndarray
|
|
Total atoms to be used in function.
|
|
power : float
|
|
Power of the reactor in [W]
|
|
print_out : bool, optional
|
|
Whether or not to print out time.
|
|
|
|
Returns
|
|
-------
|
|
openmc.deplete.OperatorResult
|
|
Eigenvalue and reaction rates resulting from transport operator
|
|
|
|
"""
|
|
# Prevent OpenMC from complaining about re-creating tallies
|
|
openmc.reset_auto_ids()
|
|
|
|
# Update status
|
|
self.number.set_density(vec)
|
|
|
|
time_start = time.time()
|
|
|
|
# Update material compositions and tally nuclides
|
|
self._update_materials()
|
|
self._tally.nuclides = self._get_tally_nuclides()
|
|
|
|
# Run OpenMC
|
|
openmc.capi.reset()
|
|
openmc.capi.run()
|
|
|
|
time_openmc = time.time()
|
|
|
|
# Extract results
|
|
op_result = self._unpack_tallies_and_normalize(power)
|
|
|
|
if comm.rank == 0:
|
|
time_unpack = time.time()
|
|
|
|
if print_out:
|
|
print("Time to openmc: ", time_openmc - time_start)
|
|
print("Time to unpack: ", time_unpack - time_openmc)
|
|
|
|
return copy.deepcopy(op_result)
|
|
|
|
def _get_burnable_mats(self):
|
|
"""Determine depletable materials, volumes, and nuclids
|
|
|
|
Returns
|
|
-------
|
|
burnable_mats : list of str
|
|
List of burnable material IDs
|
|
volume : OrderedDict of str to float
|
|
Volume of each material in [cm^3]
|
|
nuclides : list of str
|
|
Nuclides in order of how they'll appear in the simulation.
|
|
|
|
"""
|
|
burnable_mats = set()
|
|
model_nuclides = set()
|
|
volume = OrderedDict()
|
|
|
|
# Iterate once through the geometry to get dictionaries
|
|
for mat in self.geometry.get_all_materials().values():
|
|
for nuclide in mat.get_nuclides():
|
|
model_nuclides.add(nuclide)
|
|
if mat.depletable:
|
|
burnable_mats.add(str(mat.id))
|
|
if mat.volume is None:
|
|
raise RuntimeError("Volume not specified for depletable "
|
|
"material with ID={}.".format(mat.id))
|
|
volume[str(mat.id)] = mat.volume
|
|
|
|
# Make sure there are burnable materials
|
|
if not burnable_mats:
|
|
raise RuntimeError(
|
|
"No depletable materials were found in the model.")
|
|
|
|
# Sort the sets
|
|
burnable_mats = sorted(burnable_mats, key=int)
|
|
model_nuclides = sorted(model_nuclides)
|
|
|
|
# Construct a global nuclide dictionary, burned first
|
|
nuclides = list(self.chain.nuclide_dict)
|
|
for nuc in model_nuclides:
|
|
if nuc not in nuclides:
|
|
nuclides.append(nuc)
|
|
|
|
return burnable_mats, volume, nuclides
|
|
|
|
def _extract_number(self, local_mats, volume, nuclides, prev_res=None):
|
|
"""Construct AtomNumber using geometry
|
|
|
|
Parameters
|
|
----------
|
|
local_mats : list of str
|
|
Material IDs to be managed by this process
|
|
volume : OrderedDict of str to float
|
|
Volumes for the above materials in [cm^3]
|
|
nuclides : list of str
|
|
Nuclides to be used in the simulation.
|
|
prev_res : ResultsList, optional
|
|
Results from a previous depletion calculation
|
|
|
|
"""
|
|
self.number = AtomNumber(local_mats, nuclides, volume, len(self.chain))
|
|
|
|
if self.dilute_initial != 0.0:
|
|
for nuc in self._burnable_nucs:
|
|
self.number.set_atom_density(np.s_[:], nuc, self.dilute_initial)
|
|
|
|
# Now extract and store the number densities
|
|
# From the geometry if no previous depletion results
|
|
if prev_res is None:
|
|
for mat in self.geometry.get_all_materials().values():
|
|
if str(mat.id) in local_mats:
|
|
self._set_number_from_mat(mat)
|
|
|
|
# Else from previous depletion results
|
|
else:
|
|
for mat in self.geometry.get_all_materials().values():
|
|
if str(mat.id) in local_mats:
|
|
self._set_number_from_results(mat, prev_res)
|
|
|
|
def _set_number_from_mat(self, mat):
|
|
"""Extracts material and number densities from openmc.Material
|
|
|
|
Parameters
|
|
----------
|
|
mat : openmc.Material
|
|
The material to read from
|
|
|
|
"""
|
|
mat_id = str(mat.id)
|
|
|
|
for nuclide, density in mat.get_nuclide_atom_densities().values():
|
|
number = density * 1.0e24
|
|
self.number.set_atom_density(mat_id, nuclide, number)
|
|
|
|
def _set_number_from_results(self, mat, prev_res):
|
|
"""Extracts material nuclides and number densities.
|
|
|
|
If the nuclide concentration's evolution is tracked, the densities come
|
|
from depletion results. Else, densities are extracted from the geometry
|
|
in the summary.
|
|
|
|
Parameters
|
|
----------
|
|
mat : openmc.Material
|
|
The material to read from
|
|
prev_res : ResultsList
|
|
Results from a previous depletion calculation
|
|
|
|
"""
|
|
mat_id = str(mat.id)
|
|
|
|
# Get nuclide lists from geometry and depletion results
|
|
depl_nuc = prev_res[-1].nuc_to_ind
|
|
geom_nuc_densities = mat.get_nuclide_atom_densities()
|
|
|
|
# Merge lists of nuclides, with the same order for every calculation
|
|
geom_nuc_densities.update(depl_nuc)
|
|
|
|
for nuclide in geom_nuc_densities.keys():
|
|
if nuclide in depl_nuc:
|
|
concentration = prev_res.get_atoms(mat_id, nuclide)[1][-1]
|
|
volume = prev_res[-1].volume[mat_id]
|
|
number = concentration / volume
|
|
else:
|
|
density = geom_nuc_densities[nuclide][1]
|
|
number = density * 1.0e24
|
|
|
|
self.number.set_atom_density(mat_id, nuclide, number)
|
|
|
|
def initial_condition(self):
|
|
"""Performs final setup and returns initial condition.
|
|
|
|
Returns
|
|
-------
|
|
list of numpy.ndarray
|
|
Total density for initial conditions.
|
|
"""
|
|
|
|
# Create XML files
|
|
if comm.rank == 0:
|
|
self.geometry.export_to_xml()
|
|
self.settings.export_to_xml()
|
|
self._generate_materials_xml()
|
|
|
|
# Initialize OpenMC library
|
|
comm.barrier()
|
|
openmc.capi.init(intracomm=comm)
|
|
|
|
# Generate tallies in memory
|
|
self._generate_tallies()
|
|
|
|
# Return number density vector
|
|
return list(self.number.get_mat_slice(np.s_[:]))
|
|
|
|
def finalize(self):
|
|
"""Finalize a depletion simulation and release resources."""
|
|
openmc.capi.finalize()
|
|
|
|
def _update_materials(self):
|
|
"""Updates material compositions in OpenMC on all processes."""
|
|
|
|
for rank in range(comm.size):
|
|
number_i = comm.bcast(self.number, root=rank)
|
|
|
|
for mat in number_i.materials:
|
|
nuclides = []
|
|
densities = []
|
|
for nuc in number_i.nuclides:
|
|
if nuc in self.nuclides_with_data:
|
|
val = 1.0e-24 * number_i.get_atom_density(mat, nuc)
|
|
|
|
# If nuclide is zero, do not add to the problem.
|
|
if val > 0.0:
|
|
if self.round_number:
|
|
val_magnitude = np.floor(np.log10(val))
|
|
val_scaled = val / 10**val_magnitude
|
|
val_round = round(val_scaled, 8)
|
|
|
|
val = val_round * 10**val_magnitude
|
|
|
|
nuclides.append(nuc)
|
|
densities.append(val)
|
|
else:
|
|
# Only output warnings if values are significantly
|
|
# negative. CRAM does not guarantee positive values.
|
|
if val < -1.0e-21:
|
|
print("WARNING: nuclide ", nuc, " in material ", mat,
|
|
" is negative (density = ", val, " at/barn-cm)")
|
|
number_i[mat, nuc] = 0.0
|
|
|
|
# Update densities on C API side
|
|
mat_internal = openmc.capi.materials[int(mat)]
|
|
mat_internal.set_densities(nuclides, densities)
|
|
|
|
#TODO Update densities on the Python side, otherwise the
|
|
# summary.h5 file contains densities at the first time step
|
|
|
|
def _generate_materials_xml(self):
|
|
"""Creates materials.xml from self.number.
|
|
|
|
Due to uncertainty with how MPI interacts with OpenMC API, this
|
|
constructs the XML manually. The long term goal is to do this
|
|
through direct memory writing.
|
|
|
|
"""
|
|
materials = openmc.Materials(self.geometry.get_all_materials()
|
|
.values())
|
|
|
|
# Sort nuclides according to order in AtomNumber object
|
|
nuclides = list(self.number.nuclides)
|
|
for mat in materials:
|
|
mat._nuclides.sort(key=lambda x: nuclides.index(x[0]))
|
|
|
|
materials.export_to_xml()
|
|
|
|
def _get_tally_nuclides(self):
|
|
"""Determine nuclides that should be tallied for reaction rates.
|
|
|
|
This method returns a list of all nuclides that have neutron data and
|
|
are listed in the depletion chain. Technically, we should tally nuclides
|
|
that may not appear in the depletion chain because we still need to get
|
|
the fission reaction rate for these nuclides in order to normalize
|
|
power, but that is left as a future exercise.
|
|
|
|
Returns
|
|
-------
|
|
list of str
|
|
Tally nuclides
|
|
|
|
"""
|
|
nuc_set = set()
|
|
|
|
# Create the set of all nuclides in the decay chain in materials marked
|
|
# for burning in which the number density is greater than zero.
|
|
for nuc in self.number.nuclides:
|
|
if nuc in self.nuclides_with_data:
|
|
if np.sum(self.number[:, nuc]) > 0.0:
|
|
nuc_set.add(nuc)
|
|
|
|
# Communicate which nuclides have nonzeros to rank 0
|
|
if comm.rank == 0:
|
|
for i in range(1, comm.size):
|
|
nuc_newset = comm.recv(source=i, tag=i)
|
|
nuc_set |= nuc_newset
|
|
else:
|
|
comm.send(nuc_set, dest=0, tag=comm.rank)
|
|
|
|
if comm.rank == 0:
|
|
# Sort nuclides in the same order as self.number
|
|
nuc_list = [nuc for nuc in self.number.nuclides
|
|
if nuc in nuc_set]
|
|
else:
|
|
nuc_list = None
|
|
|
|
# Store list of tally nuclides on each process
|
|
nuc_list = comm.bcast(nuc_list)
|
|
return [nuc for nuc in nuc_list if nuc in self.chain]
|
|
|
|
def _generate_tallies(self):
|
|
"""Generates depletion tallies.
|
|
|
|
Using information from the depletion chain as well as the nuclides
|
|
currently in the problem, this function automatically generates a
|
|
tally.xml for the simulation.
|
|
|
|
"""
|
|
# Create tallies for depleting regions
|
|
materials = [openmc.capi.materials[int(i)]
|
|
for i in self.burnable_mats]
|
|
mat_filter = openmc.capi.MaterialFilter(materials)
|
|
|
|
# Set up a tally that has a material filter covering each depletable
|
|
# material and scores corresponding to all reactions that cause
|
|
# transmutation. The nuclides for the tally are set later when eval() is
|
|
# called.
|
|
self._tally = openmc.capi.Tally()
|
|
self._tally.scores = self.chain.reactions
|
|
self._tally.filters = [mat_filter]
|
|
|
|
def _unpack_tallies_and_normalize(self, power):
|
|
"""Unpack tallies from OpenMC and return an operator result
|
|
|
|
This method uses OpenMC's C API bindings to determine the k-effective
|
|
value and reaction rates from the simulation. The reaction rates are
|
|
normalized by the user-specified power, summing the product of the
|
|
fission reaction rate times the fission Q value for each material.
|
|
|
|
Parameters
|
|
----------
|
|
power : float
|
|
Power of the reactor in [W]
|
|
|
|
Returns
|
|
-------
|
|
openmc.deplete.OperatorResult
|
|
Eigenvalue and reaction rates resulting from transport operator
|
|
|
|
"""
|
|
rates = self.reaction_rates
|
|
rates[:, :, :] = 0.0
|
|
|
|
k_combined = openmc.capi.keff()[0]
|
|
|
|
# Extract tally bins
|
|
materials = self.burnable_mats
|
|
nuclides = self._tally.nuclides
|
|
|
|
# Form fast map
|
|
nuc_ind = [rates.index_nuc[nuc] for nuc in nuclides]
|
|
react_ind = [rates.index_rx[react] for react in self.chain.reactions]
|
|
|
|
# Compute fission power
|
|
# TODO : improve this calculation
|
|
|
|
# Keep track of energy produced from all reactions in eV per source
|
|
# particle
|
|
energy = 0.0
|
|
|
|
# Create arrays to store fission Q values, reaction rates, and nuclide
|
|
# numbers
|
|
fission_Q = np.zeros(rates.n_nuc)
|
|
rates_expanded = np.zeros((rates.n_nuc, rates.n_react))
|
|
number = np.zeros(rates.n_nuc)
|
|
|
|
fission_ind = rates.index_rx["fission"]
|
|
|
|
for nuclide in self.chain.nuclides:
|
|
if nuclide.name in rates.index_nuc:
|
|
for rx in nuclide.reactions:
|
|
if rx.type == 'fission':
|
|
ind = rates.index_nuc[nuclide.name]
|
|
fission_Q[ind] = rx.Q
|
|
break
|
|
|
|
# Extract results
|
|
for i, mat in enumerate(self.local_mats):
|
|
# Get tally index
|
|
slab = materials.index(mat)
|
|
|
|
# Get material results hyperslab
|
|
results = self._tally.results[slab, :, 1]
|
|
|
|
# Zero out reaction rates and nuclide numbers
|
|
rates_expanded[:] = 0.0
|
|
number[:] = 0.0
|
|
|
|
# Expand into our memory layout
|
|
j = 0
|
|
for nuc, i_nuc_results in zip(nuclides, nuc_ind):
|
|
number[i_nuc_results] = self.number[mat, nuc]
|
|
for react in react_ind:
|
|
rates_expanded[i_nuc_results, react] = results[j]
|
|
j += 1
|
|
|
|
# Accumulate energy from fission
|
|
energy += np.dot(rates_expanded[:, fission_ind], fission_Q)
|
|
|
|
# Divide by total number and store
|
|
for i_nuc_results in nuc_ind:
|
|
if number[i_nuc_results] != 0.0:
|
|
for react in react_ind:
|
|
rates_expanded[i_nuc_results, react] /= number[i_nuc_results]
|
|
|
|
rates[i, :, :] = rates_expanded
|
|
|
|
# Reduce energy produced from all processes
|
|
energy = comm.allreduce(energy)
|
|
|
|
# Determine power in eV/s
|
|
power /= JOULE_PER_EV
|
|
|
|
# Scale reaction rates to obtain units of reactions/sec
|
|
rates *= power / energy
|
|
|
|
return OperatorResult(k_combined, rates)
|
|
|
|
def _get_nuclides_with_data(self):
|
|
"""Loads a cross_sections.xml file to find participating nuclides.
|
|
|
|
This allows for nuclides that are important in the decay chain but not
|
|
important neutronically, or have no cross section data.
|
|
"""
|
|
|
|
# Reads cross_sections.xml to create a dictionary containing
|
|
# participating (burning and not just decaying) nuclides.
|
|
|
|
try:
|
|
filename = os.environ["OPENMC_CROSS_SECTIONS"]
|
|
except KeyError:
|
|
filename = None
|
|
|
|
nuclides = set()
|
|
|
|
try:
|
|
tree = ET.parse(filename)
|
|
except Exception:
|
|
if filename is None:
|
|
msg = "No cross_sections.xml specified in materials."
|
|
else:
|
|
msg = 'Cross section file "{}" is invalid.'.format(filename)
|
|
raise IOError(msg)
|
|
|
|
root = tree.getroot()
|
|
for nuclide_node in root.findall('library'):
|
|
mats = nuclide_node.get('materials')
|
|
if not mats:
|
|
continue
|
|
for name in mats.split():
|
|
# Make a burn list of the union of nuclides in cross_sections.xml
|
|
# and nuclides in depletion chain.
|
|
if name not in nuclides:
|
|
nuclides.add(name)
|
|
|
|
return nuclides
|
|
|
|
def get_results_info(self):
|
|
"""Returns volume list, material lists, and nuc lists.
|
|
|
|
Returns
|
|
-------
|
|
volume : dict of str float
|
|
Volumes corresponding to materials in full_burn_dict
|
|
nuc_list : list of str
|
|
A list of all nuclide names. Used for sorting the simulation.
|
|
burn_list : list of int
|
|
A list of all material IDs to be burned. Used for sorting the simulation.
|
|
full_burn_list : list
|
|
List of all burnable material IDs
|
|
|
|
"""
|
|
nuc_list = self.number.burnable_nuclides
|
|
burn_list = self.local_mats
|
|
|
|
volume = {}
|
|
for i, mat in enumerate(burn_list):
|
|
volume[mat] = self.number.volume[i]
|
|
|
|
# Combine volume dictionaries across processes
|
|
volume_list = comm.allgather(volume)
|
|
volume = {k: v for d in volume_list for k, v in d.items()}
|
|
|
|
return volume, nuc_list, burn_list, self.burnable_mats
|