Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 1D Cylindrical Modeling to the XS Group Manager #1238

Merged
merged 12 commits into from
Apr 27, 2023
134 changes: 128 additions & 6 deletions armi/physics/neutronics/crossSectionGroupManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,113 @@ def getNumberDensityWithTrace(component, nucName):
return nvt, nv


class CylindricalComponentsAverageBlockCollection(BlockCollection):
"""
Creates a representative block for the purpose of cross section generation with a one-dimensional
cylindrical model.

Notes
-----
When generating the representative block within this collection, the geometry is checked
against all other blocks to ensure that the number of components are consistent. This implementation
is intended to be opinionated, so if a user attempts to put blocks that have geometric differences
then this will fail.

This selects a representative block based on the collection of candidates based on the
median block average temperatures as an assumption.
"""

def _getNewBlock(self):
newBlock = copy.deepcopy(self._selectCandidateBlock())
newBlock.name = "1D_CYL_AVG_" + newBlock.getMicroSuffix()
return newBlock

def _selectCandidateBlock(self):
"""Selects the candidate block with the median block-average temperature."""
info = []
for b in self.getCandidateBlocks():
info.append((b.getAverageTempInC(), b.getName(), b))
info.sort()
medianBlockData = info[len(info) // 2]
return medianBlockData[-1]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the median block temperature?

Copy link
Contributor

@mgjarrett mgjarrett Apr 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, this is what @jakehader originally implemented. We do have a MedianBlockCollection but it's based on the median burnup, not the median temperature. Average probably does make more sense for temperature.

Edit: Although I guess it doesn't matter because then we just deepcopy it and then set the number densities based on the whole block collection. So it's probably not too important which block gets selected here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it's probably not too important which block gets selected here.

Agreed. You could probably simplify _selectCandidateBlock to use just the first block in getCandidateBlocks(). For what it's worth, the unit test within this PR still works with that change.


def _makeRepresentativeBlock(self):
"""Build a representative fuel block based on component number densities."""
repBlock = self._getNewBlock()
bWeights = [self.getWeight(b) for b in self.getCandidateBlocks()]
componentsInOrder = self._orderComponentsInGroup(repBlock)

for c, allSimilarComponents in zip(repBlock, componentsInOrder):
allNucsNames, densities = self._getAverageComponentNucs(
allSimilarComponents, bWeights
)
for nuc, aDensity in zip(allNucsNames, densities):
c.setNumberDensity(nuc, aDensity)
return repBlock

@staticmethod
def _getAllNucs(components):
"""Iterate through components and get all unique nuclides."""
nucs = set()
for c in components:
nucs = nucs.union(c.getNuclides())
return sorted(list(nucs))

@staticmethod
def _checkComponentConsistency(b, repBlock):
"""
Verify that all components being homogenized have same multiplicity and nuclides

Raises
------
ValueError
When the components in a candidate block do not align with
the components in the representative block. This check includes component area, component multiplicity,
and nuclide composition.
"""
mgjarrett marked this conversation as resolved.
Show resolved Hide resolved
if len(b) != len(repBlock):
raise ValueError(
f"Blocks {b} and {repBlock} have differing number "
f"of components and cannot be homogenized"
)
for c, repC in zip(b, repBlock):
compString = (
f"Component {repC} in block {repBlock} and component {c} in block {b}"
)
if c.p.mult != repC.p.mult:
raise ValueError(
f"{compString} must have the same multiplicity, but they have."
f"{repC.p.mult} and {c.p.mult}, respectively."
)

theseNucs = set(c.getNuclides())
thoseNucs = set(repC.getNuclides())
diffNucs = theseNucs.symmetric_difference(thoseNucs)
if diffNucs:
raise ValueError(
f"{compString} are in the same location, but nuclides "
f"differ by {diffNucs}. \n{theseNucs} \n{thoseNucs}"
)

def _getAverageComponentNucs(self, components, bWeights):
"""Compute average nuclide densities by block weights and component area fractions."""
allNucNames = self._getAllNucs(components)
densities = numpy.zeros(len(allNucNames))
totalWeight = 0.0
for c, bWeight in zip(components, bWeights):
weight = bWeight * c.getArea()
totalWeight += weight
densities += weight * numpy.array(c.getNuclideNumberDensities(allNucNames))
return allNucNames, densities / totalWeight

def _orderComponentsInGroup(self, repBlock):
"""Order the components based on dimension and material type within the representative block."""
for b in self.getCandidateBlocks():
self._checkComponentConsistency(b, repBlock)
componentLists = [list(b) for b in self.getCandidateBlocks()]
return [list(comps) for comps in zip(*componentLists)]


class SlabComponentsAverageBlockCollection(BlockCollection):
"""
Creates a representative 1D slab block.
Expand All @@ -402,14 +509,19 @@ class SlabComponentsAverageBlockCollection(BlockCollection):

"""

def _getNewBlock(self):
newBlock = copy.deepcopy(self.getCandidateBlocks()[0])
newBlock.name = "1D_SLAB_AVG_" + newBlock.getMicroSuffix()
return newBlock

def _makeRepresentativeBlock(self):
"""Build a representative fuel block based on component number densities."""
repBlock = self._getNewBlock()
bWeights = [self.getWeight(b) for b in self.getCandidateBlocks()]
componentsInOrder = self._orderComponentsInGroup(repBlock)

for c, allSimilarComponents in zip(repBlock, componentsInOrder):
allNucsNames, densities = self._getAverageComponantNucs(
allNucsNames, densities = self._getAverageComponentNucs(
allSimilarComponents, bWeights
)
for nuc, aDensity in zip(allNucsNames, densities):
Expand Down Expand Up @@ -510,7 +622,7 @@ def _removeLatticeComponents(repBlock):
repBlock.remove(c)
return repBlock

def _getAverageComponantNucs(self, components, bWeights):
def _getAverageComponentNucs(self, components, bWeights):
"""Compute average nuclide densities by block weights and component area fractions."""
allNucNames = self._getAllNucs(components)
densities = numpy.zeros(len(allNucNames))
Expand Down Expand Up @@ -1160,11 +1272,21 @@ def updateNuclideTemperatures(self, blockCollectionByXsGroup=None):
runLog.extra("XS ID: {}, Collection: {}".format(xsID, collection))


# String constants
MEDIAN_BLOCK_COLLECTION = "Median"
AVERAGE_BLOCK_COLLECTION = "Average"
FLUX_WEIGHTED_AVERAGE_BLOCK_COLLECTION = "FluxWeightedAverage"
SLAB_COMPONENTS_BLOCK_COLLECTION = "ComponentAverage1DSlab"
CYLINDRICAL_COMPONENTS_BLOCK_COLLECTION = "ComponentAverage1DCylinder"

# Mapping between block collection string constants and their
# respective block collection classes.
BLOCK_COLLECTIONS = {
"Median": MedianBlockCollection,
"Average": AverageBlockCollection,
"ComponentAverage1DSlab": SlabComponentsAverageBlockCollection,
"FluxWeightedAverage": FluxWeightedAverageBlockCollection,
MEDIAN_BLOCK_COLLECTION: MedianBlockCollection,
AVERAGE_BLOCK_COLLECTION: AverageBlockCollection,
FLUX_WEIGHTED_AVERAGE_BLOCK_COLLECTION: FluxWeightedAverageBlockCollection,
SLAB_COMPONENTS_BLOCK_COLLECTION: SlabComponentsAverageBlockCollection,
CYLINDRICAL_COMPONENTS_BLOCK_COLLECTION: CylindricalComponentsAverageBlockCollection,
}


Expand Down
117 changes: 96 additions & 21 deletions armi/physics/neutronics/crossSectionSettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@
See detailed docs in `:doc: Lattice Physics <reference/physics/neutronics/latticePhysics/latticePhysics>`.
"""

from enum import Enum
from typing import Dict, Union

import voluptuous as vol

from armi import runLog
from armi.physics.neutronics import crossSectionGroupManager
from armi.physics.neutronics.crossSectionGroupManager import BLOCK_COLLECTIONS
from armi.settings import Setting
from armi import context
Expand All @@ -53,19 +55,55 @@
CONF_XS_EXECUTE_EXCLUSIVE = "xsExecuteExclusive"
CONF_XS_PRIORITY = "xsPriority"

# These may be used as arguments to ``latticePhysicsInterface._getGeomDependentWriters``.
# This could be an ENUM later.

class XSGeometryTypes(Enum):
"""
Data structure for storing the available geometry options
within the framework.
"""

ZERO_DIMENSIONAL = 1
ONE_DIMENSIONAL_SLAB = 2
ONE_DIMENSIONAL_CYLINDER = 4
TWO_DIMENSIONAL_HEX = 8

@classmethod
def _mapping(cls):
mapping = {
cls.ZERO_DIMENSIONAL: "0D",
cls.ONE_DIMENSIONAL_SLAB: "1D slab",
cls.ONE_DIMENSIONAL_CYLINDER: "1D cylinder",
cls.TWO_DIMENSIONAL_HEX: "2D hex",
}
return mapping

@classmethod
def getStr(cls, typeSpec: Enum):
"""
Return a string representation of the given ``typeSpec``.

Examples
--------
XSGeometryTypes.getStr(XSGeometryTypes.ZERO_DIMENSIONAL) == "0D"
XSGeometryTypes.getStr(XSGeometryTypes.TWO_DIMENSIONAL_HEX) == "2D hex"
"""
geometryTypes = list(cls)
if typeSpec not in geometryTypes:
raise TypeError(f"{typeSpec} not in {geometryTypes}")
return cls._mapping()[cls[typeSpec.name]]


XS_GEOM_TYPES = {
"0D",
"1D slab",
"1D cylinder",
"2D hex",
XSGeometryTypes.getStr(XSGeometryTypes.ZERO_DIMENSIONAL),
XSGeometryTypes.getStr(XSGeometryTypes.ONE_DIMENSIONAL_SLAB),
XSGeometryTypes.getStr(XSGeometryTypes.ONE_DIMENSIONAL_CYLINDER),
XSGeometryTypes.getStr(XSGeometryTypes.TWO_DIMENSIONAL_HEX),
}

# This dictionary defines the valid set of inputs based on
# the geometry type within the ``XSModelingOptions``
_VALID_INPUTS_BY_GEOMETRY_TYPE = {
"0D": {
XSGeometryTypes.getStr(XSGeometryTypes.ZERO_DIMENSIONAL): {
albeanth marked this conversation as resolved.
Show resolved Hide resolved
CONF_XSID,
CONF_GEOM,
CONF_BUCKLING,
Expand All @@ -76,7 +114,7 @@
CONF_XS_EXECUTE_EXCLUSIVE,
CONF_XS_PRIORITY,
},
"1D slab": {
XSGeometryTypes.getStr(XSGeometryTypes.ONE_DIMENSIONAL_SLAB): {
CONF_XSID,
CONF_GEOM,
CONF_MESH_PER_CM,
Expand All @@ -86,7 +124,7 @@
CONF_XS_EXECUTE_EXCLUSIVE,
CONF_XS_PRIORITY,
},
"1D cylinder": {
XSGeometryTypes.getStr(XSGeometryTypes.ONE_DIMENSIONAL_CYLINDER): {
CONF_XSID,
CONF_GEOM,
CONF_MERGE_INTO_CLAD,
Expand All @@ -101,7 +139,7 @@
CONF_XS_EXECUTE_EXCLUSIVE,
CONF_XS_PRIORITY,
},
"2D hex": {
XSGeometryTypes.getStr(XSGeometryTypes.TWO_DIMENSIONAL_HEX): {
CONF_XSID,
CONF_GEOM,
CONF_BUCKLING,
Expand Down Expand Up @@ -263,7 +301,9 @@ def _getDefault(self, xsID):
"before attempting to add a new XS ID."
)

xsOpt = XSModelingOptions(xsID, geometry="0D")
xsOpt = XSModelingOptions(
xsID, geometry=XSGeometryTypes.getStr(XSGeometryTypes.ZERO_DIMENSIONAL)
)
xsOpt.setDefaults(self._blockRepresentation, self._validBlockTypes)
xsOpt.validate()
return xsOpt
Expand Down Expand Up @@ -551,43 +591,70 @@ def setDefaults(self, blockRepresentation, validBlockTypes):

defaults = {}
if self.xsIsPregenerated:
allowableBlockCollections = [
crossSectionGroupManager.MEDIAN_BLOCK_COLLECTION,
crossSectionGroupManager.AVERAGE_BLOCK_COLLECTION,
crossSectionGroupManager.FLUX_WEIGHTED_AVERAGE_BLOCK_COLLECTION,
]
defaults = {
CONF_XS_FILE_LOCATION: self.xsFileLocation,
CONF_BLOCK_REPRESENTATION: blockRepresentation,
}

elif self.geometry == "0D":
elif self.geometry == XSGeometryTypes.getStr(XSGeometryTypes.ZERO_DIMENSIONAL):
allowableBlockCollections = [
crossSectionGroupManager.MEDIAN_BLOCK_COLLECTION,
crossSectionGroupManager.AVERAGE_BLOCK_COLLECTION,
crossSectionGroupManager.FLUX_WEIGHTED_AVERAGE_BLOCK_COLLECTION,
]
bucklingSearch = False if self.fluxIsPregenerated else True
defaults = {
CONF_GEOM: "0D",
CONF_GEOM: self.geometry,
CONF_BUCKLING: bucklingSearch,
CONF_DRIVER: "",
CONF_BLOCK_REPRESENTATION: blockRepresentation,
CONF_BLOCKTYPES: validBlockTypes,
CONF_EXTERNAL_FLUX_FILE_LOCATION: self.fluxFileLocation,
}
elif self.geometry == "1D slab":
elif self.geometry == XSGeometryTypes.getStr(
XSGeometryTypes.ONE_DIMENSIONAL_SLAB
):
allowableBlockCollections = [
crossSectionGroupManager.SLAB_COMPONENTS_BLOCK_COLLECTION,
]
defaults = {
CONF_GEOM: "1D slab",
CONF_GEOM: self.geometry,
CONF_MESH_PER_CM: 1.0,
CONF_BLOCK_REPRESENTATION: blockRepresentation,
CONF_BLOCK_REPRESENTATION: crossSectionGroupManager.SLAB_COMPONENTS_BLOCK_COLLECTION,
CONF_BLOCKTYPES: validBlockTypes,
}
elif self.geometry == "1D cylinder":
elif self.geometry == XSGeometryTypes.getStr(
XSGeometryTypes.ONE_DIMENSIONAL_CYLINDER
):
allowableBlockCollections = [
crossSectionGroupManager.CYLINDRICAL_COMPONENTS_BLOCK_COLLECTION
]
defaults = {
CONF_GEOM: "1D cylinder",
CONF_GEOM: self.geometry,
CONF_DRIVER: "",
CONF_MERGE_INTO_CLAD: ["gap"],
CONF_MESH_PER_CM: 1.0,
CONF_INTERNAL_RINGS: 0,
CONF_EXTERNAL_RINGS: 1,
CONF_HOMOGBLOCK: False,
CONF_BLOCK_REPRESENTATION: blockRepresentation,
CONF_BLOCK_REPRESENTATION: crossSectionGroupManager.CYLINDRICAL_COMPONENTS_BLOCK_COLLECTION,
CONF_BLOCKTYPES: validBlockTypes,
}
elif self.geometry == "2D hex":
elif self.geometry == XSGeometryTypes.getStr(
XSGeometryTypes.TWO_DIMENSIONAL_HEX
):
allowableBlockCollections = [
crossSectionGroupManager.MEDIAN_BLOCK_COLLECTION,
crossSectionGroupManager.AVERAGE_BLOCK_COLLECTION,
crossSectionGroupManager.FLUX_WEIGHTED_AVERAGE_BLOCK_COLLECTION,
]
defaults = {
CONF_GEOM: "2D hex",
CONF_GEOM: self.geometry,
CONF_BUCKLING: False,
CONF_EXTERNAL_DRIVER: True,
CONF_DRIVER: "",
Expand All @@ -603,6 +670,14 @@ def setDefaults(self, blockRepresentation, validBlockTypes):
currentValue = getattr(self, attrName)
if currentValue is None:
setattr(self, attrName, defaultValue)
else:
if attrName == CONF_BLOCK_REPRESENTATION:
if currentValue not in allowableBlockCollections:
raise ValueError(
f"Invalid block collection type `{currentValue}` assigned "
f"for {self.xsID}. Expected one of the "
f"following: {allowableBlockCollections}"
)

self.validate()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,6 @@ def __init__(self, r, cs):

# Set to True by default, but should be disabled when perturbed cross sections are generated.
self._updateBlockNeutronVelocities = True
# Geometry options available through the lattice physics interfaces
self._ZERO_DIMENSIONAL_GEOM = "0D"
self._ONE_DIMENSIONAL_GEOM = "1D"
self._TWO_DIMENSIONAL_GEOM = "2D"
self._SLAB_MODEL = " slab"
self._CYLINDER_MODEL = " cylinder"
self._HEX_MODEL = " hex"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these removed because they are unused?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see they were used downstream but no longer are. Ok.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They have been replaced by the XSGeometryTypes Enum. This could affect other users' downstream repos that use the latticePhysicsInterface, but it seems like good practice to force the usage of XSGeometryTypes throughout.

Copy link
Member

@albeanth albeanth Apr 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sombrereau @drewj-usnctech tagging for visibility and a heads up

self._burnupTolerance = self.cs[CONF_TOLERATE_BURNUP_CHANGE]
self._oldXsIdsAndBurnup = {}
self.executablePath = self._getExecutablePath()
Expand Down
Loading