# Copyright 2019 TerraPower, LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Defines nuclide flags and custom isotopics via input.
Nuclide flags control meta-data about nuclides. Custom isotopics
allow specification of arbitrary isotopic compositions.
"""
import yamlize
from armi import materials
from armi import runLog
from armi.nucDirectory import elements
from armi.nucDirectory import nucDir
from armi.nucDirectory import nuclideBases
from armi.utils import densityTools
from armi.utils import units
from armi.utils.customExceptions import InputError
from armi.physics.neutronics.fissionProductModel.fissionProductModelSettings import (
CONF_FP_MODEL,
CONF_FISSION_PRODUCT_LIBRARY_NAME,
)
from armi.physics.neutronics.settings import (
CONF_NEUTRONICS_KERNEL,
CONF_XS_KERNEL,
)
ALLOWED_KEYS = set(nuclideBases.byName.keys()) | set(elements.bySymbol.keys())
[docs]class NuclideFlag(yamlize.Object):
"""
Defines whether or not each nuclide is included in the burn chain and cross sections.
Also controls which nuclides get expanded from elementals to isotopics
and which natural isotopics to exclude (if any). Oftentimes, cross section
library creators include some natural isotopes but not all. For example,
it is common to include O16 but not O17 or O18. Each code has slightly
different interpretations of this so we give the user full control here.
We also try to provide useful defaults.
There are lots of complications that can arise in these choices.
It makes reasonable sense to use elemental compositions
for things that are typically used without isotopic modifications
(Fe, O, Zr, Cr, Na). If we choose to expand some or all of these
to isotopics at initialization based on cross section library
requirements, a single case will work fine with a given lattice
physics option. However, restarting from that case with different
cross section needs is challenging.
Attributes
----------
nuclideName : str
The name of the nuclide
burn : bool
True if this nuclide should be added to the burn chain.
If True, all reachable nuclides via transmutation
and decay must be included as well.
xs : bool
True if this nuclide should be included in the cross
section libraries. Effectively, if this nuclide is in the problem
at all, this should be true.
expandTo : list of str, optional
isotope nuclideNames to expand to. For example, if nuclideName is
``O`` then this could be ``["O16", "O17"]`` to expand it into
those two isotopes (but not ``O18``). The nuclides will be scaled
up uniformly to account for any missing natural nuclides.
"""
nuclideName = yamlize.Attribute(type=str)
@nuclideName.validator
def nuclideName(self, value): # pylint: disable=method-hidden
if value not in ALLOWED_KEYS:
raise ValueError(
"`{}` is not a valid nuclide name, must be one of: {}".format(
value, ALLOWED_KEYS
)
)
burn = yamlize.Attribute(type=bool)
xs = yamlize.Attribute(type=bool)
expandTo = yamlize.Attribute(type=yamlize.StrList, default=None)
def __init__(self, nuclideName, burn, xs, expandTo):
# note: yamlize does not call an __init__ method, instead it uses __new__ and setattr
self.nuclideName = nuclideName
self.burn = burn
self.xs = xs
self.expandTo = expandTo
def __repr__(self):
return "<NuclideFlag name:{} burn:{} xs:{}>".format(
self.nuclideName, self.burn, self.xs
)
[docs] def fileAsActiveOrInert(
self, activeSet, inertSet, undefinedBurnChainActiveNuclides
):
"""
Given a nuclide or element name, file it as either active or inert.
If isotopic expansions are requested, include the isotopics
rather than the NaturalNuclideBase, as the NaturalNuclideBase will never
occur in such a problem.
"""
nb = nuclideBases.byName[self.nuclideName]
if self.expandTo:
nucBases = [nuclideBases.byName[nn] for nn in self.expandTo]
expanded = [nb.element] # error to expand non-elements
else:
nucBases = [nb]
expanded = []
for nuc in nucBases:
if self.burn:
if not nuc.trans and not nuc.decays:
# DUMPs and LFPs usually
undefinedBurnChainActiveNuclides.add(nuc.name)
activeSet.add(nuc.name)
if self.xs:
inertSet.add(nuc.name)
return expanded
[docs]class NuclideFlags(yamlize.KeyedList):
"""
An OrderedDict of ``NuclideFlags``, keyed by their ``nuclideName``.
"""
item_type = NuclideFlag
key_attr = NuclideFlag.nuclideName
[docs]class CustomIsotopic(yamlize.Map):
"""
User specified, custom isotopics input defined by a name (such as MOX), and key/pairs of nuclide names and numeric
values consistent with the ``input format``.
"""
key_type = yamlize.Typed(str)
value_type = yamlize.Typed(float)
name = yamlize.Attribute(type=str)
inputFormat = yamlize.Attribute(key="input format", type=str)
@inputFormat.validator
def inputFormat(self, value): # pylint: disable=method-hidden
if value not in self._allowedFormats:
raise ValueError(
"Cannot set `inputFormat` to `{}`, must be one of: {}".format(
value, self._allowedFormats
)
)
_density = yamlize.Attribute(key="density", type=float, default=None)
_allowedFormats = {"number fractions", "number densities", "mass fractions"}
def __new__(cls, *args):
self = yamlize.Map.__new__(cls, *args)
# the density as computed by source number densities
self._computedDensity = None
return self
def __init__(self, name, inputFormat, density):
# note: yamlize does not call an __init__ method, instead it uses __new__ and setattr
self._name = None
self.name = name
self._inputFormat = None
self.inputFormat = inputFormat
self.density = density
self.massFracs = {}
def __setitem__(self, key, value):
if key not in ALLOWED_KEYS:
raise ValueError(
"Key `{}` is not valid, must be one of: {}".format(key, ALLOWED_KEYS)
)
yamlize.Map.__setitem__(self, key, value)
@property
def density(self):
return self._computedDensity or self._density
@density.setter
def density(self, value):
if self._computedDensity is not None:
raise AttributeError(
"Density was computed from number densities, and should not be "
"set directly."
)
self._density = value
if value is not None and value < 0:
raise ValueError(
"Cannot set `density` to `{}`, must greater than 0".format(value)
)
[docs] @classmethod
def from_yaml(cls, loader, node, rtd):
"""
Override the ``Yamlizable.from_yaml`` to inject custom data validation logic, and complete initialization of the
object.
"""
self = yamlize.Map.from_yaml.__func__(cls, loader, node, rtd)
try:
self._initializeMassFracs()
self._expandElementMassFracs()
except Exception as ex:
# use a YamlizingError to get line/column of erroneous input
raise yamlize.YamlizingError(str(ex), node)
return self
[docs] @classmethod
def from_yaml_key_val(cls, loader, key_node, val_node, key_attr, rtd):
"""
Override the ``Yamlizable.from_yaml`` to inject custom data validation logic, and complete initialization of the
object.
"""
self = yamlize.Map.from_yaml_key_val.__func__(
cls, loader, key_node, val_node, key_attr, rtd
)
try:
self._initializeMassFracs()
self._expandElementMassFracs()
except Exception as ex:
# use a YamlizingError to get line/column of erroneous input
raise yamlize.YamlizingError(str(ex), val_node)
return self
def _initializeMassFracs(self):
self.massFracs = dict() # defaults to 0.0, __init__ is not called
if any(v < 0.0 for v in self.values()):
raise ValueError(
"Custom isotopic input for {} is negative".format(self.name)
)
valSum = sum(self.values())
if not abs(valSum - 1.0) < 1e-5 and "fractions" in self.inputFormat:
raise ValueError(
"Fractional custom isotopic input values must sum to 1.0 in: {}".format(
self.name
)
)
if self.inputFormat == "number fractions":
sumNjAj = 0.0
for nuc, nj in self.items():
if nj:
sumNjAj += nj * nucDir.getAtomicWeight(nuc)
for nuc, value in self.items():
massFrac = value * nucDir.getAtomicWeight(nuc) / sumNjAj
self.massFracs[nuc] = massFrac
elif self.inputFormat == "number densities":
if self._density is not None:
raise InputError(
"Custom isotopic `{}` is over-specified. It was provided as number "
"densities, and but density ({}) was also provided. Is the input format "
"correct?".format(self.name, self.density)
)
M = {
nuc: Ni
/ units.MOLES_PER_CC_TO_ATOMS_PER_BARN_CM
* nucDir.getAtomicWeight(nuc)
for nuc, Ni in self.items()
}
densityTotal = sum(M.values())
if densityTotal < 0:
raise ValueError("Computed density is negative")
for nuc, Mi in M.items():
self.massFracs[nuc] = Mi / densityTotal
self._computedDensity = densityTotal
elif self.inputFormat == "mass fractions":
self.massFracs = dict(self) # as input
else:
raise ValueError(
"Unrecognized custom isotopics input format {}.".format(
self.inputFormat
)
)
def _expandElementMassFracs(self):
"""
Expand the custom isotopics input entries that are elementals to isotopics.
This is necessary when the element name is not a elemental nuclide.
Most everywhere else expects Nuclide objects (or nuclide names). This input allows a
user to enter "U" which would expand to the naturally occurring uranium isotopics.
This is different than the isotopic expansion done for meeting user-specified
modeling options (such as an MC**2, or MCNP expecting elements or isotopes),
because it translates the user input into something that can be used later on.
"""
elementsToExpand = []
for nucName in self.massFracs:
if nucName not in nuclideBases.byName:
element = elements.bySymbol.get(nucName)
if element is not None:
runLog.info(
"Expanding custom isotopic `{}` element `{}` to natural isotopics".format(
self.name, nucName
)
)
# include all natural isotopes with None flag
elementsToExpand.append((element, None))
else:
raise InputError(
"Unrecognized nuclide/isotope/element in input: {}".format(
nucName
)
)
densityTools.expandElementalMassFracsToNuclides(
self.massFracs, elementsToExpand
)
[docs] def apply(self, material):
"""
Apply specific isotopic compositions to a component.
Generically, materials have composition-dependent bulk properties such as mass density.
Note that this operation does not update these material properties. Use with care.
Parameters
----------
material : Material
An ARMI Material instance.
"""
material.massFrac = dict(self.massFracs)
if self.density is not None:
if not isinstance(material, materials.Custom):
runLog.warning(
"You either specified a custom mass density or number densities "
"(which implies a mass density) on `{}` with custom isotopics `{}`. "
"This has no effect on this Material class; you can only "
"override mass density on `Custom` "
"materials. Consider switching to number fraction input. "
"Continuing to use {} mass density.".format(
material, self.name, material
)
)
# specifically, non-Custom materials only use refDensity and dLL, mat.customDensity has no effect
return
material.customDensity = self.density
[docs]class CustomIsotopics(yamlize.KeyedList):
"""
OrderedDict of CustomIsotopic objects, keyed by their name.
"""
item_type = CustomIsotopic
key_attr = CustomIsotopic.name
# note: yamlize does not call an __init__ method, instead it uses __new__ and setattr
[docs] def apply(self, material, customIsotopicsName):
"""
Apply specific isotopic compositions to a component.
Generically, materials have composition-dependent bulk properties such as mass density.
Note that this operation does not update these material properties. Use with care.
Parameters
----------
material : Material
Material instance to adjust.
customIsotopicName : str
String corresponding to the ``CustomIsoptopic.name``.
"""
if customIsotopicsName not in self:
raise KeyError(
"The input custom isotopics do not include {}. "
"The only present specifications are {}".format(
customIsotopicsName, self.keys()
)
)
custom = self[customIsotopicsName]
custom.apply(material)
[docs]def getDefaultNuclideFlags():
"""
Return a default set of nuclides to model and deplete.
Notes
-----
The nuclideFlags input on blueprints has confused new users and is infrequently
changed. It will be moved to be a user setting, but in any case a reasonable default
should be provided. We will by default model medium-lived and longer actinides between
U234 and CM247.
We will include B10 and B11 without depletion, sodium, and structural elements.
We will include LFPs with depletion.
"""
nuclideFlags = {}
actinides = {
"U": [234, 235, 236, 238],
"NP": [237, 238],
"PU": [236] + list(range(238, 243)),
"AM": range(241, 244),
"CM": range(242, 248),
}
for el, masses in actinides.items():
for mass in masses:
nuclideFlags[f"{el}{mass}"] = {"burn": True, "xs": True, "expandTo": None}
for fp in [35, 38, 39, 40, 41]:
nuclideFlags[f"LFP{fp}"] = {"burn": True, "xs": True, "expandTo": None}
for dmp in [1, 2]:
nuclideFlags[f"DUMP{dmp}"] = {"burn": True, "xs": True, "expandTo": None}
for boron in [10, 11]:
nuclideFlags[f"B{boron}"] = {"burn": False, "xs": True, "expandTo": None}
for struct in ["ZR", "C", "SI", "V", "CR", "MN", "FE", "NI", "MO", "W", "NA", "HE"]:
nuclideFlags[struct] = {"burn": False, "xs": True, "expandTo": None}
return nuclideFlags
[docs]def autoSelectElementsToKeepFromSettings(cs):
"""
Intelligently choose elements to expand based on settings.
If settings point to a particular code and library and we know
that combo requires certain elementals to be expanded, we
flag them here to make the user input as simple as possible.
This determines both which elementals to keep and which
specific expansion subsets to use.
Notes
-----
This logic is expected to be moved to respective plugins in time.
Returns
-------
elementalsToKeep : set
Set of NaturalNuclideBase instances to not expand into
natural isotopics.
expansions : dict
Element to list of nuclides for expansion.
For example: {oxygen: [oxygen16]} indicates that all
oxygen should be expanded to O16, ignoring natural
O17 and O18. (variables are Natural/NuclideBases)
"""
elementalsToKeep = set()
oxygenElementals = [nuclideBases.byName["O"]]
hydrogenElementals = [nuclideBases.byName[name] for name in ["H"]]
endf70Elementals = [nuclideBases.byName[name] for name in ["C", "V", "ZN"]]
endf71Elementals = [nuclideBases.byName[name] for name in ["C"]]
endf80Elementals = []
elementalsInMC2 = set()
expansionStrings = {}
mc2Expansions = {
"HE": ["HE4"], # neglect HE3
"O": ["O16"], # neglect O17 and O18
"W": ["W182", "W183", "W184", "W186"], # neglect W180
}
mcnpExpansions = {"O": ["O16"]}
for element in elements.byName.values():
# any NaturalNuclideBase that's available in MC2 libs
nnb = nuclideBases.byName.get(element.symbol)
if nnb and nnb.getMcc2Id():
elementalsInMC2.add(nnb)
if "MCNP" in cs[CONF_NEUTRONICS_KERNEL]:
expansionStrings.update(mcnpExpansions)
if int(cs["mcnpLibrary"]) == 50:
elementalsToKeep.update(nuclideBases.instances) # skip expansion
# ENDF/B VII.0
elif 70 <= int(cs["mcnpLibrary"]) <= 79:
elementalsToKeep.update(endf70Elementals)
# ENDF/B VII.1
elif 80 <= int(cs["mcnpLibrary"]) <= 89:
elementalsToKeep.update(endf71Elementals)
else:
raise InputError(
"Failed to determine nuclides for modeling. "
"The `mcnpLibrary` setting value ({}) is not supported.".format(
cs["mcnpLibrary"]
)
)
elif cs[CONF_XS_KERNEL] in ["", "SERPENT", "MC2v3", "MC2v3-PARTISN"]:
elementalsToKeep.update(endf70Elementals)
expansionStrings.update(mc2Expansions)
elif cs[CONF_XS_KERNEL] == "DRAGON":
# Users need to use default nuclear lib name. This is documented.
dragLib = cs["dragonDataPath"]
# only supports ENDF/B VII/VIII at the moment.
if "7r0" in dragLib:
elementalsToKeep.update(endf70Elementals)
elif "7r1" in dragLib:
elementalsToKeep.update(endf71Elementals)
elif "8r0" in dragLib:
elementalsToKeep.update(endf80Elementals)
elementalsToKeep.update(hydrogenElementals)
elementalsToKeep.update(oxygenElementals)
else:
raise ValueError(
f"Unrecognized DRAGLIB name: {dragLib} Use default file name."
)
elif cs[CONF_XS_KERNEL] == "MC2v2":
# strip out any NaturalNuclideBase with no getMcc2Id() (not on mcc-nuclides.yaml)
elementalsToKeep.update(elementalsInMC2)
expansionStrings.update(mc2Expansions)
# convert convenient string notation to actual NuclideBase objects
expansions = {}
for nnb, nbs in expansionStrings.items():
expansions[nuclideBases.byName[nnb]] = [nuclideBases.byName[nb] for nb in nbs]
return elementalsToKeep, expansions
[docs]def genDefaultNucFlags():
"""Perform all the yamlize-required type conversions."""
flagsDict = getDefaultNuclideFlags()
flags = NuclideFlags()
for nucName, nucFlags in flagsDict.items():
flag = NuclideFlag(
nucName, nucFlags["burn"], nucFlags["xs"], nucFlags["expandTo"]
)
flags[nucName] = flag
return flags
[docs]def autoUpdateNuclideFlags(cs, nuclideFlags):
"""
This function is responsible for examining the fission product model treatment
that is selected by the user and adding a set of nuclides to the `nuclideFlags`
list.
Notes
-----
The reason for adding this method is that when switching between fission product
modeling treatments it can be time-consuming to manually adjust the ``nuclideFlags``
inputs.
See Also
--------
genDefaultNucFlags
"""
nbs = getAllNuclideBasesByLibrary(cs)
if nbs:
runLog.info(
f"Adding explicit fission products to the nuclide flags based on the "
f"fission product model set to `{cs[CONF_FP_MODEL]}`."
)
for nb in nbs:
nuc = nb.name
if nuc in nuclideFlags or elements.byZ[nb.z] in nuclideFlags:
continue
nuclideFlag = NuclideFlag(nuc, burn=False, xs=True, expandTo=[])
nuclideFlags[nuc] = nuclideFlag
[docs]def getAllNuclideBasesByLibrary(cs):
"""
Return a list of nuclide bases available for cross section modeling
based on the ``CONF_FISSION_PRODUCT_LIBRARY_NAME`` setting.
"""
nbs = []
if cs[CONF_FP_MODEL] == "explicitFissionProducts":
if not cs[CONF_FISSION_PRODUCT_LIBRARY_NAME]:
nbs = []
if cs[CONF_FISSION_PRODUCT_LIBRARY_NAME] == "MC2-3":
nbs = nuclideBases.byMcc3Id.values()
else:
raise ValueError(
f"An option to handle the `CONF_FISSION_PRODUCT_LIBRARY_NAME` "
f"set to `{cs[CONF_FISSION_PRODUCT_LIBRARY_NAME]}` has not been "
f"implemented."
)
return nbs