# 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.
"""
Components represent geometric objects within an assembly such as fuel, bond, coolant, ducts, wires, etc.
This module contains the abstract definition of a Component.
"""
import re
import copy
import numpy
import six
from armi.materials import material
from armi.materials import custom
from armi import runLog
from armi.bookkeeping import report
from armi.reactor import composites
from armi.reactor import parameters
from armi.reactor.components import componentParameters
from armi.utils import densityTools
from armi.utils.units import C_TO_K
from armi.materials import void
from armi.nucDirectory import nuclideBases
from armi import materials
from armi.reactor.flags import Flags
COMPONENT_LINK_REGEX = re.compile(r"^\s*(.+?)\s*\.\s*(.+?)\s*$")
_NICE_DIM_NAMES = {
"id": "Inner Diameter (cm)",
"od": "Outer Diameter (cm)",
"ip": "Inner Pitch (cm)",
"op": "Outer Pitch (cm)",
"mult": "Multiplicity",
"axialPitch": "Axial Pitch (cm)",
"helixDiameter": "Helix Diameter (cm)",
"length": "Length (cm)",
"height": "Height (cm)",
"width": "Width (cm)",
"areaMod": "Area Mod. Factor",
}
[docs]def componentTypeIsValid(component, name):
"""
Checks that the component assigned component type is valid
Notes
-----
- `Coolant` components are can no longer be defined as a general `Component` and should be specfied as a
`DerivedShape` if the coolant dimensions are not provided.
"""
from armi.reactor.components import NullComponent
if name.lower() == "coolant":
invalidComponentTypes = [Component, NullComponent]
if component.__class__ in invalidComponentTypes:
raise ValueError(
"Coolant components cannot be defined as a `Component`. Either define coolant as a "
"`DerivedShape` or specify its dimensions explicitly using another component type."
)
[docs]class _DimensionLink(tuple):
"""
A linked dimension, where one component uses a dimension from another.
Useful when the boundaries are physically shared and should move together.
The tuple contains (linkedComponent, linkedDimensionName).
In equating two components, we need the linked dimensions to resolve responsibly/precisely.
"""
[docs] def getLinkedComponent(self):
"""Return the linked component."""
return self[0]
[docs] def resolveDimension(self, Tc=None, cold=False):
"""Return the current value of the linked dimension."""
linkedComponent = self[0]
dimID = self[1]
return linkedComponent.getDimension(dimID, Tc=Tc, cold=cold)
def __eq__(self, other):
otherDimension = (
other.resolveDimension() if isinstance(other, _DimensionLink) else other
)
return self.resolveDimension() == otherDimension
def __ne__(self, other):
return not self.__eq__(other)
[docs] def __str__(self):
"""Return a string representation of a dimension link.
These look like ``otherComponentName.otherDimensionName``.
For example, if a link were to a ``fuel`` component's
``od`` param, the link would render as ``fuel.od``.
"""
return f"{self[0].name}.{self[1]}"
[docs]class ComponentType(composites.CompositeModelType):
"""
ComponetType is a metaclass for storing and initializing Component subclass types.
The construction of Component subclasses is being done through factories for ease of
user input. As a consequence, the ``__init__`` methods' arguments need to be known
in order to conform them to the correct format. Additionally, the constructors
arguments can be used to determine the Component subclasses dimensions.
.. warning:: The import-time metaclass-based component subclass registration was a
good idea, but in practice has caused significant confusion and trouble. We will
replace this soon with an explicit plugin-based component subclass registration
system.
"""
TYPES = dict()
NON_DIMENSION_NAMES = (
"Tinput",
"Thot",
"isotopics",
"mergeWith",
"material",
"name",
"components",
"area",
)
def __new__(cls, name, bases, attrs):
newType = composites.CompositeModelType.__new__(cls, name, bases, attrs)
ComponentType.TYPES[name.lower()] = newType
# the co_varnames attribute contains arguments and then locals so we must
# restrict it to just the arguments.
signature = newType.__init__.__code__.co_varnames[
1 : newType.__init__.__code__.co_argcount
]
# INIT_SIGNATURE and DIMENSION_NAMES are in the same order as the method signature
newType.INIT_SIGNATURE = tuple(signature)
newType.DIMENSION_NAMES = tuple(
k
for k in newType.INIT_SIGNATURE
if k not in ComponentType.NON_DIMENSION_NAMES
)
return newType
[docs]class Component(composites.Composite, metaclass=ComponentType):
"""
A primitive object in a reactor that has definite area/volume, material and composition.
Could be fuel pins, cladding, duct, wire wrap, etc. One component object may represent
multiple physical components via the ``multiplicity`` mechanism.
Attributes
----------
temperatureInC : float
Current temperature of component in celcius.
inputTemperatureInC : float
Reference temperature in C at which dimension definitions were input
temperatureInC : float
Temperature in C to which dimensions were thermally-expanded upon input.
material : str or material.Material
The material object that makes up this component and give it its thermo-mechanical properties.
"""
DIMENSION_NAMES = tuple() # will be assigned by ComponentType
INIT_SIGNATURE = tuple() # will be assigned by ComponentType
is3D = False # flag to show that area is 2D by default
_COMP_REPORT_GROUPS = {
"intercoolant": report.INTERCOOLANT_DIMS,
"bond": report.BOND_DIMS,
"duct": report.DUCT_DIMS,
"coolant": report.COOLANT_DIMS,
"clad": report.CLAD_DIMS,
"fuel": report.FUEL_DIMS,
"wire": report.WIRE_DIMS,
"liner": report.LINER_DIMS,
"gap": report.GAP_DIMS,
}
_TOLERANCE = 1e-10
THERMAL_EXPANSION_DIMS = set()
pDefs = componentParameters.getComponentParameterDefinitions()
def __init__(
self,
name,
material,
Tinput,
Thot,
area=None,
isotopics="",
mergeWith="",
components=None,
):
if components and name in components:
raise ValueError(
"Non-unique component name {} repeated in same block.".format(name)
)
composites.Composite.__init__(self, str(name))
componentTypeIsValid(self, str(name))
self.p.area = area
self.inputTemperatureInC = Tinput
self.temperatureInC = Thot
self.material = None
self.setProperties(material)
self.applyMaterialMassFracsToNumberDensities() # not necessary when duplicating...
self.setType(name)
self.p.mergeWith = mergeWith
self.p.customIsotopicsName = isotopics
@property
def temperatureInC(self):
"""Return the hot temperature in Celsius."""
return self.p.temperatureInC
@temperatureInC.setter
def temperatureInC(self, value):
"""Set the hot temperature in Celsius."""
self.p.temperatureInC = value
@property
def temperatureInK(self):
"""Current hot temperature in Kelvin."""
return self.temperatureInC + C_TO_K
[docs] def __lt__(self, other):
"""
True if a circle encompassing this object has a smaller diameter than one encompassing another component.
This allows sorting because the Python sort functions only use this method.
"""
thisOD = self.getBoundingCircleOuterDiameter(cold=True)
thatOD = other.getBoundingCircleOuterDiameter(cold=True)
try:
return thisOD < thatOD
except:
raise ValueError(
"Components 1 ({} with OD {}) and 2 ({} and OD {}) cannot be ordered because their "
"bounding circle outer diameters are not comparable.".format(
self, thisOD, other, thatOD
)
)
def __setstate__(self, state):
composites.Composite.__setstate__(self, state)
self.material.parent = self
[docs] def _linkAndStoreDimensions(self, components, **dims):
"""Link dimensions to another component"""
for key, val in dims.items():
self.setDimension(key, val)
if components:
self._resolveLinkedDims(components)
[docs] def _resolveLinkedDims(self, components):
for dimName in self.DIMENSION_NAMES:
value = self.p[dimName]
if not isinstance(value, six.string_types):
continue
match = COMPONENT_LINK_REGEX.search(value)
if match:
try:
name = match.group(1)
comp = components[name]
linkedKey = match.group(2)
self.p[dimName] = _DimensionLink((comp, linkedKey))
except:
raise KeyError(
"Bad component link `{}` defined as `{}`".format(dimName, value)
)
[docs] def setLink(self, key, otherComp, otherCompKey):
"""Set the dimension link."""
self.p[key] = _DimensionLink((otherComp, otherCompKey))
[docs] def setProperties(self, properties):
"""Apply thermo-mechanical properties of a Material."""
if isinstance(properties, str):
mat = materials.resolveMaterialClassByName(properties)()
# note that the material will not be expanded to natural isotopics
# here because the user-input blueprints information is not available
else:
mat = properties
self.material = mat
self.material.parent = self
self.clearLinkedCache()
[docs] def applyMaterialMassFracsToNumberDensities(self):
"""
Set initial (hot) number densities of this component based Material composition.
Notes
-----
We apply the hot-height density reduction here to account for pre-expanded
block heights in blueprints.
Future temperature changes can be handled by multiplications of 1/(1+dLL)**2
instead of 1/(1+dLL)**3 since we have pre-expanded in the axial direction.
"""
denistyIfNotPreExpandedAxially = self.material.getProperty(
"density", self.temperatureInK
)
# axial expansion factor must be applied because ARMI expects hot heights
# to be entered on assemblies in the blueprints so that it doesn't have to
# handle the problem of fuel axially expanding at a different rate than clad.
axialExpansionFactor = 1.0 + self.material.linearExpansionFactor(
self.temperatureInC, self.inputTemperatureInC
)
self.p.numberDensities = densityTools.getNDensFromMasses(
denistyIfNotPreExpandedAxially / axialExpansionFactor,
self.material.p.massFrac,
)
[docs] def getProperties(self):
"""Return the active Material object defining thermo-mechanical properties."""
return self.material
@property
def liquidPorosity(self):
return self.parent.p.liquidPorosity
@liquidPorosity.setter
def liquidPorosity(self, porosity):
self.parent.p.liquidPorosity = porosity
@property
def gasPorosity(self):
return self.parent.p.gasPorosity
@gasPorosity.setter
def gasPorosity(self, porosity):
self.parent.p.gasPorosity = porosity
[docs] def __copy__(self):
"""
Duplicate a component, used for breaking fuel into separate components.
"""
linkedDims = self._getLinkedDimsAndValues()
newC = copy.deepcopy(self)
self._restoreLinkedDims(linkedDims)
newC._restoreLinkedDims(linkedDims)
return newC
[docs] def setLumpedFissionProducts(self, lfpCollection):
"""Sets lumped fission product collection on a lfp compatible material if possible"""
try:
self.getProperties().setLumpedFissionProducts(lfpCollection)
except AttributeError:
# This material doesn't setLumpedFissionProducts because it's a regular
# material, not a lumpedFissionProductCompatable material
pass
[docs] def getArea(self, cold=False):
"""
Get the area of a component in cm^2.
See Also
--------
block.getVolumeFractions: component coolant is typically the "leftover" and is calculated and set here
"""
area = self.getComponentArea(cold=cold)
if self.p.get("modArea", None): # pylint: disable=no-member
comp, arg = self.p.modArea
if arg == "sub":
area -= comp.getComponentArea(cold=cold)
elif arg == "add":
area += comp.getComponentArea(cold=cold)
else:
raise ValueError("Option {} does not exist".format(arg))
self._checkNegativeArea(area, cold)
return area
[docs] def getVolume(self):
"""
Return the volume [cm^3] of the component.
Notes
-----
``self.p.volume`` is not set until this method is called,
so under most circumstances it is probably not safe to
access ``self.p.volume`` directly. This is because not
all components (e.g., ``DerivedShape``) can compute
their volume during initialization.
"""
if self.p.volume is None:
self._updateVolume()
if self.p.volume is None:
raise ValueError("{} has undefined volume.".format(self))
return self.p.volume
[docs] def clearCache(self):
"""
Invalidate the volume so that it will be recomputed from current dimensions upon next access.
The updated value will be based on its shape and current dimensions.
If there is a parent container and that container contains a DerivedShape, then that must be
updated as well since its volume may be changing.
See Also
--------
clearLinkedCache: Clears cache of components that depend on this component's dimensions.
"""
self.p.volume = None
if self.parent:
self.parent.derivedMustUpdate = True
[docs] def _updateVolume(self):
"""Recompute and store volume."""
self.p.volume = self.computeVolume()
[docs] def computeVolume(self):
"""Compute volume."""
if not self.is3D:
volume = self.getArea() * self.parent.getHeight()
else:
volume = self.getComponentVolume()
self._checkNegativeVolume(volume)
return volume
[docs] def _checkNegativeArea(self, area, cold):
"""
Check for negative area and warn/error when appropriate.
Negative component area is allowed for Void materials (such as gaps)
which may be placed between components that will overlap during thermal expansion
(such as liners and cladding and annular fuel).
Overlapping is allowed to maintain conservation of atoms while sticking close
to the as-built geometry. Modules that need true geometries will have to
handle this themselves.
"""
if numpy.isnan(area):
return
if area < 0.0:
if (
cold and not self.containsVoidMaterial()
) or self.containsSolidMaterial():
negAreaFailure = (
"Component {} with {} has cold negative area of {} cm^2. "
"This can be caused by component "
"overlap with component dimension linking or by invalid inputs.".format(
self, self.material, area
)
)
raise ArithmeticError(negAreaFailure)
[docs] def _checkNegativeVolume(self, volume):
"""Check for negative volume
See Also
--------
self._checkNegativeArea
"""
if numpy.isnan(volume):
return
if volume < 0.0 and self.containsSolidMaterial():
negVolFailure = (
"Component {} with {} has cold negative volume of {} cm^3. "
"This can be caused by component "
"overlap with component dimension linking or by invalid inputs.".format(
self, self.material, volume
)
)
raise ArithmeticError(negVolFailure)
[docs] def containsVoidMaterial(self):
"""Returns True if component material is void."""
return isinstance(self.material, void.Void)
[docs] def containsSolidMaterial(self):
"""Returns True if the component material is a solid."""
return not isinstance(self.material, material.Fluid)
[docs] def getComponentArea(self, cold=False):
"""
Get the area of this component in cm^2.
Parameters
----------
cold : bool, optional
Compute the area with as-input dimensions instead of thermally-expanded
"""
raise NotImplementedError
[docs] def getComponentVolume(self):
return self.p.volume
[docs] def setVolume(self, val):
raise NotImplementedError
[docs] def setArea(self, val):
raise NotImplementedError
[docs] def setTemperature(self, temperatureInC):
r"""
Adjust temperature of this component.
This will cause thermal expansion or contraction of solid or liquid components and will
accordingly adjust number densities to conserve mass.
Liquids still have a number density adjustment, but some mass tends to expand in or out of
the bounding area.
Since some composites have multiple materials in them that thermally expand differently,
the axial dimension is generally left unchanged. Hence, this a 2-D thermal expansion.
Number density change is proportional to mass density change :math:`\frac{d\rho}{\rho}`.
A multiplicative factor :math:`f_N` to apply to number densities when going from T to T'
is as follows:
.. math::
N^{\prime} = N \cdot f_N \\
\frac{dN}{N} = f_N - 1
Since :math:`\frac{dN}{N} \sim\frac{d\rho}{\rho}`, we have:
.. math::
f_N = \frac{d\rho}{\rho} + 1 = \frac{\rho^{\prime}}{\rho}
"""
prevTemp, self.temperatureInC = self.temperatureInC, float(temperatureInC)
f = self.material.getThermalExpansionDensityReduction(
prevTemp, self.temperatureInC
)
self.changeNDensByFactor(f)
self.clearLinkedCache()
[docs] def getNuclides(self):
"""
Return nuclides in this component.
This includes anything that has been specified in here, including trace nuclides.
"""
return list(self.p.numberDensities.keys())
[docs] def getNumberDensity(self, nucName):
"""
Get the number density of nucName, return zero if it does not exist here.
Parameters
----------
nucName : str
Nuclide name
Returns
-------
number density : float
number density in atoms/bn-cm.
"""
return self.p.numberDensities.get(nucName, 0.0)
[docs] def getNuclideNumberDensities(self, nucNames):
"""Return a list of number densities for the nuc names requested."""
return [self.p.numberDensities.get(nucName, 0.0) for nucName in nucNames]
[docs] def _getNdensHelper(self):
return dict(self.p.numberDensities)
[docs] def setName(self, name):
"""Components use name for type and name."""
composites.Composite.setName(self, name)
self.setType(name)
[docs] def setNumberDensity(self, nucName, val):
"""
Set heterogeneous number density.
Parameters
----------
nucName : str
nuclide to modify
val : float
Number density to set in atoms/bn-cm (heterogeneous)
"""
self.p.numberDensities[nucName] = val
self.p.assigned = parameters.SINCE_ANYTHING
# necessary for syncMpiState
parameters.ALL_DEFINITIONS[
"numberDensities"
].assigned = parameters.SINCE_ANYTHING
[docs] def setNumberDensities(self, numberDensities):
"""
Set one or more multiple number densities. Clears out any number density not listed.
Parameters
----------
numberDensities : dict
nucName: ndens pairs.
Notes
-----
We don't just call setNumberDensity for each nuclide because we don't want to call ``getVolumeFractions``
for each nuclide (it's inefficient).
"""
self.p.numberDensities = numberDensities
[docs] def updateNumberDensities(self, numberDensities):
"""
Set one or more multiple number densities. Leaves unlisted number densities alone.
Parameters
----------
numberDensities : dict
nucName: ndens pairs.
"""
self.p.numberDensities.update(numberDensities)
# since we're updating the object the param points to but not the param itself, we have to inform
# the param system to flag it as modified so it properly syncs during ``syncMpiState``.
self.p.assigned = parameters.SINCE_ANYTHING
self.p.paramDefs["numberDensities"].assigned = parameters.SINCE_ANYTHING
[docs] def getEnrichment(self):
"""Get the mass enrichment of this component, as defined by the material."""
return self.getMassEnrichment()
[docs] def getMassEnrichment(self):
"""
Get the mass enrichment of this component, as defined by the material.
Notes
-----
Getting mass enrichment on any level higher than this is ambiguous because you may
have enriched boron in one pin and enriched uranium in another and blending those doesn't make sense.
"""
if self.material.enrichedNuclide is None:
raise ValueError(
"Cannot get enrichment of {} because `enrichedNuclide` is not defined."
"".format(self.material)
)
enrichedNuclide = nuclideBases.byName[self.material.enrichedNuclide]
baselineNucNames = [nb.name for nb in enrichedNuclide.element.nuclideBases]
massFracs = self.getMassFracs()
massFracEnrichedElement = sum(
massFrac
for nucName, massFrac in massFracs.items()
if nucName in baselineNucNames
)
try:
return (
massFracs.get(self.material.enrichedNuclide, 0.0)
/ massFracEnrichedElement
)
except ZeroDivisionError:
return 0.0
[docs] def getMass(self, nuclideNames=None):
r"""
Determine the mass in grams of nuclide(s) and/or elements in this object.
.. math::
\text{mass} = \frac{\sum_i (N_i \cdot V \cdot A_i)}{N_A \cdot 10^{-24}}
where
:math:`N_i` is number density of nuclide i in (1/bn-cm),
:math:`V` is the object volume in :math:`cm^3`
:math:`N_A` is Avogadro's number in 1/moles,
:math:`A_i` is the atomic weight of of nuclide i in grams/mole
Parameters
----------
nuclideNames : str, optional
The nuclide/element specifier to get the mass of in the object.
If omitted, total mass is returned.
Returns
-------
mass : float
The mass in grams.
"""
nuclideNames = self._getNuclidesFromSpecifier(nuclideNames)
volume = self.getVolume() / (
self.parent.getSymmetryFactor() if self.parent else 1.0
)
densities = self.getNuclideNumberDensities(nuclideNames)
return sum(
densityTools.getMassInGrams(nucName, volume, numberDensity)
for nucName, numberDensity in zip(nuclideNames, densities)
)
[docs] def setDimension(self, key, val, retainLink=False, cold=True):
"""
Set a single dimension on the component.
Parameters
----------
key : str
The dimension key (op, ip, mult, etc.)
val : float
The value to set on the dimension
retainLink : bool, optional
If True, the val will be applied to the dimension of linked
component which indirectly changes this component's dimensions.
cold : book, optional
If True sets the component to the dimension that would cause
the hot dimension to be the specified value.
"""
if not key:
return
if retainLink and self.dimensionIsLinked(key):
linkedComp, linkedDimName = self.p[key]
linkedComp.setDimension(linkedDimName, val, cold=cold)
else:
expansionFactor = (
self.getThermalExpansionFactor()
if key in self.THERMAL_EXPANSION_DIMS
else 1.0
)
val = val / expansionFactor if not cold else val
self.p[key] = val
self.clearLinkedCache()
[docs] def getDimension(self, key, Tc=None, cold=False):
"""
Return a specific dimension at temperature as determined by key
Parameters
----------
key : str
The dimension key (op, ip, mult, etc.)
Tc : float
Temperature in C. If None, the current temperature of the component is used.
cold : bool, optional
If true, will return cold (input) value of the requested dimension
"""
dimension = self.p[key]
if isinstance(dimension, _DimensionLink):
return dimension.resolveDimension(Tc=Tc, cold=cold)
if not dimension or cold or key not in self.THERMAL_EXPANSION_DIMS:
return dimension
return self.getThermalExpansionFactor(Tc) * dimension
[docs] def getBoundingCircleOuterDiameter(self, Tc=None, cold=False):
"""Abstract bounding circle method that should be overwritten by each shape subclass."""
raise NotImplementedError
[docs] def dimensionIsLinked(self, key):
"""True if a the specified dimension is linked to another dimension."""
return key in self.p and isinstance(self.p[key], _DimensionLink)
[docs] def getDimensionNamesLinkedTo(self, otherComponent):
"""Find dimension names linked to the other component in this component."""
dimNames = []
for dimName in self.DIMENSION_NAMES:
isLinked = self.dimensionIsLinked(dimName)
if isLinked and self.p[dimName].getLinkedComponent() is otherComponent:
dimNames.append((dimName, self.p[dimName][1]))
return dimNames
[docs] def clearLinkedCache(self):
"""Clear this cache and any other dependent volumes."""
self.clearCache()
if self.parent: # pylint: disable=no-member
# changes in dimensions can affect cached variables such as pitch
self.parent.cached = {}
for c in self.getLinkedComponents():
# no clearCache since parent already updated derivedMustUpdate in self.clearCache()
c.p.volume = None
[docs] def getLinkedComponents(self):
"""Find other components that are linked to this component."""
dependents = []
for child in self.parent.getChildren():
for dimName in child.DIMENSION_NAMES:
isLinked = child.dimensionIsLinked(dimName)
if isLinked and child.p[dimName].getLinkedComponent() is self:
dependents.append(child)
return dependents
[docs] def getThermalExpansionFactor(self, Tc=None, T0=None):
"""
Retrieves the material thermal expansion fraction.
Parameters
----------
Tc : float, optional
Adjusted temperature to get the thermal expansion factor at relative to the reference temperature
Returns
-------
Thermal expansion factor as a percentage (1.0 + dLL), where dLL is the linear expansion factor.
"""
if isinstance(self.material, (material.Fluid, custom.Custom)):
return 1.0 # No thermal expansion of fluids or custom materials
if T0 is None:
T0 = self.inputTemperatureInC
if Tc is None:
Tc = self.temperatureInC
dLL = self.material.linearExpansionFactor(Tc=Tc, T0=T0)
if not dLL and abs(Tc - T0) > self._TOLERANCE:
runLog.error(
"Linear expansion percent may not be implemented in the {} material class.\n"
"This method needs to be implemented on the material to allow thermal expansion."
".\nReference temperature: {}, Adjusted temperature: {}, Temperature difference: {}, "
"Specified tolerance: {}".format(
self.material, T0, Tc, (Tc - T0), self._TOLERANCE, single=True
)
)
raise RuntimeError(
"Linear expansion percent may not be implemented in the {} material "
"class.".format(self.material)
)
return 1.0 + dLL
[docs] def printContents(self, includeNuclides=True):
"""Print a listing of the dimensions and composition of this component."""
runLog.important(self)
runLog.important(self.setDimensionReport())
if includeNuclides:
for nuc in self.getNuclides():
runLog.important(
"{0:10s} {1:.7e}".format(nuc, self.getNumberDensity(nuc))
)
[docs] def setDimensionReport(self):
"""Gives a report of the dimensions of this component."""
reportGroup = None
for componentType, componentReport in self._COMP_REPORT_GROUPS.items():
if componentType in self.getName():
reportGroup = componentReport
break
if not reportGroup:
return "No report group designated for {} component.".format(self.getName())
reportGroup.header = [
"",
"Tcold ({0})".format(self.inputTemperatureInC),
"Thot ({0})".format(self.temperatureInC),
]
dimensions = {
k: self.p[k]
for k in self.DIMENSION_NAMES
if k not in ("modArea", "area") and self.p[k] is not None
} # py3 cannot format None
# Set component name and material
report.setData("Name", [self.getName(), ""], reportGroup)
report.setData("Material", [self.getProperties().name, ""], reportGroup)
for dimName in dimensions:
niceName = _NICE_DIM_NAMES.get(dimName, dimName)
refVal = self.getDimension(dimName, cold=True)
hotVal = self.getDimension(dimName)
try:
report.setData(niceName, [refVal, hotVal], reportGroup)
except ValueError:
runLog.warning(
"{0} has an invalid dimension for {1}. refVal: {2} hotVal: {3}".format(
self, dimName, refVal, hotVal
)
)
# calculate thickness if applicable.
suffix = None
if "id" in dimensions:
suffix = "d"
elif "ip" in dimensions:
suffix = "p"
if suffix:
coldIn = self.getDimension("i{0}".format(suffix), cold=True)
hotIn = self.getDimension("i{0}".format(suffix))
coldOut = self.getDimension("o{0}".format(suffix), cold=True)
hotOut = self.getDimension("o{0}".format(suffix))
if suffix and coldIn > 0.0:
hotThick = (hotOut - hotIn) / 2.0
coldThick = (coldOut - coldIn) / 2.0
vals = (
"Thickness (cm)",
"{0:.7f}".format(coldThick),
"{0:.7f}".format(hotThick),
)
report.setData(vals[0], [vals[1], vals[2]], reportGroup)
return report.ALL[reportGroup]
[docs] def updateDims(self, key="", val=None):
self.setDimension(key, val)
[docs] def mergeNuclidesInto(self, compToMergeWith):
"""
Set another component's number densities to reflect this one merged into it.
You must also modify the geometry of the other component and remove this component to conserve atoms.
"""
# record pre-merged number densities and areas
aMe = self.getArea()
aMerge = compToMergeWith.getArea()
meNDens = {
nucName: aMe / aMerge * self.getNumberDensity(nucName)
for nucName in self.getNuclides()
}
mergeNDens = {
nucName: compToMergeWith.getNumberDensity(nucName)
for nucName in compToMergeWith.getNuclides()
}
# set the new homogenized number densities from both. Allow
# overlapping nuclides.
for nucName in set(meNDens) | set(mergeNDens):
compToMergeWith.setNumberDensity(
nucName, (meNDens.get(nucName, 0.0) + mergeNDens.get(nucName, 0.0))
)
[docs] def iterComponents(self, typeSpec=None, exact=False):
if self.hasFlags(typeSpec, exact):
yield self
[docs] def backUp(self):
"""
Create and store a backup of the state.
This needed to be overridden due to linked components which actually have a parameter value of another
ARMI component.
"""
linkedDims = self._getLinkedDimsAndValues()
composites.Composite.backUp(self)
self._restoreLinkedDims(linkedDims)
[docs] def restoreBackup(self, paramsToApply):
r"""
Restore the parameters from perviously created backup.
This needed to be overridden due to linked components which actually have a parameter value of another
ARMI component.
"""
linkedDims = self._getLinkedDimsAndValues()
composites.Composite.restoreBackup(self, paramsToApply)
self._restoreLinkedDims(linkedDims)
[docs] def _getLinkedDimsAndValues(self):
linkedDims = []
for dimName in self.DIMENSION_NAMES:
# backUp and restore are called in tight loops, getting the value and
# checking here is faster than calling self.dimensionIsLinked because that
# requires and extra p.__getitem__
try:
val = self.p[dimName]
except:
raise RuntimeError(
"Could not find parameter {} defined for {}. Is the desired "
"Component class?".format(dimName, self)
)
if isinstance(val, _DimensionLink):
linkedDims.append((self.p.paramDefs[dimName].fieldName, val))
del self.p[dimName]
return linkedDims
[docs] def _restoreLinkedDims(self, linkedDims):
# force update without setting the ".assigned" flag
for fieldName, value in linkedDims:
setattr(self.p, fieldName, value)
[docs] def adjustMassEnrichment(self, massFraction):
"""
Change the mass fraction of this component.
The nuclides to adjust are defined by the material. This changes whichever nuclides are to
be enriched vs. the baseline nuclides of that element while holding mass constant. For
example it might adjust boron or uranium enrichment.
Conceptually, you could hold number of atoms, volume, or mass constant during this
operation. Historically ARMI adjusted mass fractions which was meant to keep mass constant.
If you have 20 mass % Uranium and adjust the enrichment, you will still have 20% Uranium
mass. But, the actual mass actually might change a bit because the enriched nuclide weighs
less.
See Also
--------
Material.enrichedNuclide
"""
if self.material.enrichedNuclide is None:
raise ValueError(
"Cannot adjust enrichment of {} because `enrichedNuclide` is not defined."
"".format(self.material)
)
enrichedNuclide = nuclideBases.byName[self.material.enrichedNuclide]
baselineNucNames = [nb.name for nb in enrichedNuclide.element.nuclideBases]
massFracsBefore = self.getMassFracs()
massFracEnrichedElement = sum(
massFrac
for nucName, massFrac in massFracsBefore.items()
if nucName in baselineNucNames
)
adjustedMassFracs = {
self.material.enrichedNuclide: massFracEnrichedElement * massFraction
}
baselineNucNames.remove(self.material.enrichedNuclide)
massFracTotalUnenriched = (
massFracEnrichedElement - massFracsBefore[self.material.enrichedNuclide]
)
for baseNucName in baselineNucNames:
# maintain relative mass fractions of baseline nuclides.
frac = massFracsBefore.get(baseNucName, 0.0) / massFracTotalUnenriched
if not frac:
continue
adjustedMassFracs[baseNucName] = (
massFracEnrichedElement * (1 - massFraction) * frac
)
self.setMassFracs(adjustedMassFracs)
[docs] def getIntegratedMgFlux(self, adjoint=False, gamma=False):
"""
Return the multigroup neutron tracklength in [n-cm/s]
The first entry is the first energy group (fastest neutrons). Each additional
group is the next energy group, as set in the ISOTXS library.
Parameters
----------
adjoint : bool, optional
Return adjoint flux instead of real
gamma : bool, optional
Whether to return the neutron flux or the gamma flux.
Returns
-------
integratedFlux : multigroup neutron tracklength in [n-cm/s]
"""
if self.p.pinNum is None:
# no pin-level flux is available
if not self.parent:
return numpy.zeros(1)
volumeFraction = self.getVolume() / self.parent.getVolume()
return volumeFraction * self.parent.getIntegratedMgFlux(adjoint, gamma)
# pin-level flux is available. Note that it is NOT integrated on the param level.
if gamma:
if adjoint:
raise ValueError("Adjoint gamma flux is currently unsupported.")
else:
pinFluxes = self.parent.p.pinMgFluxesGamma
else:
if adjoint:
pinFluxes = self.parent.p.pinMgFluxesAdj
else:
pinFluxes = self.parent.p.pinMgFluxes
return pinFluxes[self.p.pinNum - 1] * self.getVolume()
[docs] def density(self):
"""Returns the mass density of the object in g/cc."""
density = composites.Composite.density(self)
if not density:
# possible that there are no nuclides in this component yet. In that case, defer to Material.
density = self.material.density(Tc=self.temperatureInC)
return density
[docs] def getLumpedFissionProductCollection(self):
"""
Get collection of LFP objects. Will work for global or block-level LFP models.
Returns
-------
lfps : LumpedFissionProduct
lfpName keys , lfp object values
See Also
--------
armi.physics.neutronics.fissionProductModel.lumpedFissionProduct.LumpedFissionProduct : LFP object
"""
if self.parent:
return self.parent.getLumpedFissionProductCollection()
else:
return composites.ArmiObject.getLumpedFissionProductCollection(self)
[docs] def getReactionRates(self, nucName, nDensity=None):
"""
Parameters
----------
nucName - str
nuclide name -- e.g. 'U235'
nDensity - float
number Density
Returns
-------
rxnRates - dict
dictionary of reaction rates (rxn/s) for nG, nF, n2n, nA and nP
Note
----
if you set nDensity to 1/CM2_PER_BARN this makes 1 group cross section generation easier
"""
if nDensity is None:
nDensity = self.getNumberDensity(nucName)
try:
return getReactionRateDict(
nucName,
self.getAncestorWithFlags(Flags.CORE).lib,
self.parent.p.xsType,
self.getIntegratedMgFlux(),
nDensity,
)
except (AttributeError, KeyError):
# AttributeError because there was no library because no parent.r -- this is a armiObject without flux so
# send it some zeros
# KeyError because nucName was not in the library
return {"nG": 0, "nF": 0, "n2n": 0, "nA": 0, "nP": 0}
[docs] def getMicroSuffix(self):
return self.parent.getMicroSuffix()
[docs] def getPitchData(self):
"""
Return the pitch data that should be used to determine block pitch.
Notes
-----
This pitch data should only be used if this is the pitch defining component in
a block. The block is responsible for determining which component in it is the
pitch defining component.
"""
raise NotImplementedError(
f"Method not implemented on component {self}. "
"Please implement if this component type can be a pitch defining component."
)
[docs]class ShapedComponent(Component):
"""A component with well-defined dimensions."""
pass
[docs]def getReactionRateDict(nucName, lib, xsType, mgFlux, nDens):
"""
Parameters
----------
nucName - str
nuclide name -- e.g. 'U235'
lib - isotxs
cross section library
xsType - str
cross section type -- e.g. - 'A'
mgFlux - numpy.nArray
integrated mgFlux (n-cm/s)
nDens - float
number density (at/bn-cm)
Returns
-------
rxnRates - dict
dictionary of reaction rates (rxn/s) for nG, nF, n2n, nA and nP
Note
----
assume there is no n3n cross section in ISOTXS
"""
key = "{}{}A".format(nucName, xsType)
libNuc = lib[key]
rxnRates = {"n3n": 0}
for rxName, mgXSs in [
("nG", libNuc.micros.nGamma),
("nF", libNuc.micros.fission),
("n2n", libNuc.micros.n2n),
("nA", libNuc.micros.nalph),
("nP", libNuc.micros.np),
]:
rxnRates[rxName] = nDens * sum(mgXSs * mgFlux)
return rxnRates