# 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.
"""
Input definitions for Grids.
Grids are given names which can be referred to on other input structures (like core maps and pin
maps).
These are in turn interpreted into concrete things at lower levels. For example:
* Core map lattices get turned into :py:mod:`armi.reactor.grids`, which get set to
``core.spatialGrid``.
* Block pin map lattices get applied to the components to provide some subassembly spatial details.
Lattice inputs here are floating in space. Specific dimensions and anchor points are handled by the
lower-level objects definitions. This is intended to maximize lattice reusability.
See Also
--------
armi.utils.asciimaps
Description of the ascii maps and their formats.
Examples
--------
::
grids:
control:
geom: hex
symmetry: full
lattice map: |
- - - - - - - - - 1 1 1 1 1 1 1 1 1 4
- - - - - - - - 1 1 1 1 1 1 1 1 1 1 1
- - - - - - - 1 8 1 1 1 1 1 1 1 1 1 1
- - - - - - 1 1 1 1 1 1 1 1 1 1 1 1 1
- - - - - 1 1 1 1 1 1 1 1 1 1 1 1 1 1
- - - - 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
- - - 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
- - 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
- 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
7 1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 3 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1
1 6 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1
sfp:
geom: cartesian
lattice pitch:
x: 25.0
y: 25.0
lattice map: |
2 2 2 2 2
2 1 1 1 2
2 1 3 1 2
2 3 1 1 2
2 2 2 2 2
core:
geom: hex
symmetry: third periodic
origin:
x: 0.0
y: 10.1
z: 1.1
lattice map: |
- SH SH SH
- SH SH SH SH
SH RR RR RR SH
RR RR RR RR SH
RR RR RR RR RR SH
RR OC OC RR RR SH
OC OC OC RR RR SH
OC OC OC OC RR RR
OC MC OC OC RR SH
MC MC PC OC RR SH
MC MC MC OC OC RR
MC MC MC OC RR SH
PC MC MC OC RR SH
MC MC MC MC OC RR
IC MC MC OC RR SH
IC US MC OC RR
IC IC MC OC RR SH
IC MC MC OC RR
IC IC MC PC RR SH
"""
import copy
from io import StringIO
import itertools
from typing import Tuple
import numpy
import yamlize
from ruamel.yaml import scalarstring
from armi.utils.customExceptions import InputError
from armi.utils import asciimaps
from armi.utils.mathematics import isMonotonic
from armi.reactor import geometry, grids
from armi.reactor import blueprints
from armi import runLog
[docs]class Triplet(yamlize.Object):
"""A x, y, z triplet for coordinates or lattice pitch."""
x = yamlize.Attribute(type=float)
y = yamlize.Attribute(type=float, default=0.0)
z = yamlize.Attribute(type=float, default=0.0)
def __init__(self, x=0.0, y=0.0, z=0.0):
self.x = x
self.y = y
self.z = z
[docs]class GridBlueprint(yamlize.Object):
"""
A grid input blueprint.
These directly build Grid objects and contain information about how to populate the Grid with
child ArmiObjects for the Reactor Model.
The grids get origins either from a parent block (for pin lattices) or from a System (for Cores,
SFPs, and other components).
.. impl:: Define a lattice map in reactor core.
:id: I_ARMI_BP_GRID
:implements: R_ARMI_BP_GRID
Defines a yaml construct that allows the user to specify a grid from within their blueprints
file, including a name, geometry, dimensions, symmetry, and a map with the relative
locations of components within that grid.
Relies on the underlying infrastructure from the ``yamlize`` package for reading from text
files, serialization, and internal storage of the data.
Is implemented as part of a blueprints file by being used in key-value pairs within the
:py:class:`~armi.reactor.blueprints.gridBlueprint.Grid` class, which is imported and used as
an attribute within the larger :py:class:`~armi.reactor.blueprints.Blueprints` class.
Includes a ``construct`` method, which instantiates an instance of one of the subclasses of
:py:class:`~armi.reactor.grids.structuredgrid.StructuredGrid`. This is typically called from
within :py:meth:`~armi.reactor.blueprints.blockBlueprint.BlockBlueprint.construct`, which
then also associates the individual components in the block with locations specifed in the
grid.
Attributes
----------
name : str
The grid name
geom : str
The geometry of the grid (e.g. 'cartesian')
latticeMap : str
An asciimap representation of the lattice contents
latticeDimensions : Triplet
An x/y/z Triplet with grid dimensions in cm. This is used to specify a uniform grid, such as
Cartesian or Hex. Mutually exclusive with gridBounds.
gridBounds : dict
A dictionary containing explicit grid boundaries. Specific keys used will depend on the type
of grid being defined. Mutually exclusive with latticeDimensions.
symmetry : str
A string defining the symmetry mode of the grid
gridContents : dict
A {(i,j): str} dictionary mapping spatialGrid indices in 2-D to string specifiers of what's
supposed to be in the grid.
"""
name = yamlize.Attribute(key="name", type=str)
geom = yamlize.Attribute(key="geom", type=str, default=geometry.HEX)
latticeMap = yamlize.Attribute(key="lattice map", type=str, default=None)
latticeDimensions = yamlize.Attribute(
key="lattice pitch", type=Triplet, default=None
)
gridBounds = yamlize.Attribute(key="grid bounds", type=dict, default=None)
symmetry = yamlize.Attribute(
key="symmetry",
type=str,
default=str(
geometry.SymmetryType(
geometry.DomainType.THIRD_CORE, geometry.BoundaryType.PERIODIC
)
),
)
# gridContents is the final form of grid contents information; it is set regardless of how the
# input is read. When writing, we attempt to preserve the input mode and write ascii map if that
# was what was originally provided.
gridContents = yamlize.Attribute(key="grid contents", type=dict, default=None)
@gridContents.validator
def gridContents(self, value):
if value is None:
return True
if not all(isinstance(key, tuple) for key in value.keys()):
raise InputError(
"Keys need to be presented as [i, j]. Check the blueprints."
)
return True
def __init__(
self,
name=None,
geom=geometry.HEX,
latticeMap=None,
symmetry=str(
geometry.SymmetryType(
geometry.DomainType.THIRD_CORE, geometry.BoundaryType.PERIODIC
)
),
gridContents=None,
gridBounds=None,
):
"""
A Grid blueprint.
Notes
-----
yamlize does not call an ``__init__`` method, instead it uses ``__new__`` and
setattr this is only needed for when you want to make this object from a non-YAML
source.
Warning
-------
This is a Yamlize object, so ``__init__`` never really gets called. Only ``__new__`` does.
"""
self.name = name
self.geom = str(geom)
self.latticeMap = latticeMap
self._readFromLatticeMap = False
self.symmetry = str(symmetry)
self.gridContents = gridContents
self.gridBounds = gridBounds
@property
def readFromLatticeMap(self):
"""
This is implemented as a property, since as a Yamlize object, ``__init__`` is not always called
and we have to lazily evaluate its default value.
"""
return getattr(self, "_readFromLatticeMap", False)
@readFromLatticeMap.setter
def readFromLatticeMap(self, value):
self._readFromLatticeMap = value
[docs] def construct(self):
"""Build a Grid from a grid definition."""
self._readGridContents()
grid = self._constructSpatialGrid()
return grid
def _constructSpatialGrid(self):
"""
Build spatial grid.
If you do not enter ``latticeDimensions``, a unit grid will be produced which must be adjusted
to the proper dimensions (often by inspection of children) at a later time.
"""
symmetry = (
geometry.SymmetryType.fromStr(self.symmetry) if self.symmetry else None
)
geom = self.geom
maxIndex = self._getMaxIndex()
runLog.extra("Creating the spatial grid")
if geom in (geometry.RZT, geometry.RZ):
if self.gridBounds is None:
# This check is regrettably late. It would be nice if we could validate
# that bounds are provided if R-Theta mesh is being used.
raise InputError(
"Grid bounds must be provided for `{}` to specify a grid with "
"r-theta components.".format(self.name)
)
for key in ("theta", "r"):
if key not in self.gridBounds:
raise InputError(
"{} grid bounds were not provided for `{}`.".format(
key, self.name
)
)
# convert to list, otherwise it is a CommentedSeq
theta = numpy.array(self.gridBounds["theta"])
radii = numpy.array(self.gridBounds["r"])
for lst, name in ((theta, "theta"), (radii, "radii")):
if not isMonotonic(lst, "<"):
raise InputError(
"Grid bounds for {}:{} is not sorted or contains "
"duplicates. Check blueprints.".format(self.name, name)
)
spatialGrid = grids.ThetaRZGrid(bounds=(theta, radii, (0.0, 0.0)))
if geom in (geometry.HEX, geometry.HEX_CORNERS_UP):
pitch = self.latticeDimensions.x if self.latticeDimensions else 1.0
# add 2 for potential dummy assems
spatialGrid = grids.HexGrid.fromPitch(
pitch,
numRings=maxIndex + 2,
cornersUp=geom == geometry.HEX_CORNERS_UP,
)
elif geom == geometry.CARTESIAN:
# if full core or not cut-off, bump the first assembly from the center of
# the mesh into the positive values.
xw, yw = (
(self.latticeDimensions.x, self.latticeDimensions.y)
if self.latticeDimensions
else (1.0, 1.0)
)
# Specifically in the case of grid blueprints, where we have grid contents
# available, we can also infer "through center" based on the contents.
# Note that the "through center" symmetry check cannot be performed when
# the grid contents has not been provided (i.e., None or empty).
if self.gridContents and symmetry.domain == geometry.DomainType.FULL_CORE:
nx, ny = _getGridSize(self.gridContents.keys())
if nx == ny and nx % 2 == 1:
symmetry.isThroughCenterAssembly = True
isOffset = symmetry is not None and not symmetry.isThroughCenterAssembly
spatialGrid = grids.CartesianGrid.fromRectangle(
xw, yw, numRings=maxIndex + 1, isOffset=isOffset
)
runLog.debug("Built grid: {}".format(spatialGrid))
# set geometric metadata on spatialGrid. This information is needed in various
# parts of the code and is best encapsulated on the grid itself rather than on
# the container state.
spatialGrid._geomType: str = str(self.geom)
self.symmetry = str(symmetry)
spatialGrid._symmetry: str = self.symmetry
return spatialGrid
def _getMaxIndex(self):
"""
Find the max index in the grid contents.
Used to limit the size of the spatialGrid. Used to be
called maxNumRings.
"""
if self.gridContents:
return max(itertools.chain(*zip(*self.gridContents.keys())))
else:
return 6
[docs] def expandToFull(self):
"""
Unfold the blueprints to represent full symmetry.
Notes
-----
This relatively rudimentary, and copies entries from the currently-represented domain to
their corresponding locations in full symmetry. This may not produce the desired behavior
for some scenarios, such as when expanding fuel shuffling paths or the like. Future work may
make this more sophisticated.
"""
if (
geometry.SymmetryType.fromAny(self.symmetry).domain
== geometry.DomainType.FULL_CORE
):
# No need!
return
grid = self.construct()
newContents = copy.copy(self.gridContents)
for idx, contents in self.gridContents.items():
equivs = grid.getSymmetricEquivalents(idx)
for idx2 in equivs:
newContents[idx2] = contents
self.gridContents = newContents
split = geometry.THROUGH_CENTER_ASSEMBLY in self.symmetry
self.symmetry = str(
geometry.SymmetryType(
geometry.DomainType.FULL_CORE,
geometry.BoundaryType.NO_SYMMETRY,
throughCenterAssembly=split,
)
)
def _readGridContents(self):
"""
Read the specifiers as a function of grid position.
The contents can either be provided as:
* A dict mapping indices to specifiers (default output of this)
* An asciimap
The output will always be stored in ``self.gridContents``.
"""
if self.gridContents:
return
elif self.latticeMap:
self._readGridContentsLattice()
if self.gridContents is None:
# Make sure we have at least something; clients shouldn't have to worry
# about whether gridContents exist at all.
self.gridContents = dict()
def _readGridContentsLattice(self):
"""Read an ascii map of grid contents.
This update the gridContents attribute, which is a dict mapping grid i,j,k indices to
textual specifiers (e.g. ``IC``))
"""
self.readFromLatticeMap = True
symmetry = geometry.SymmetryType.fromStr(self.symmetry)
geom = geometry.GeomType.fromStr(self.geom)
latticeCls = asciimaps.asciiMapFromGeomAndDomain(self.geom, symmetry.domain)
asciimap = latticeCls()
asciimap.readAscii(self.latticeMap)
self.gridContents = dict()
iOffset = 0
jOffset = 0
if (
geom == geometry.GeomType.CARTESIAN
and symmetry.domain == geometry.DomainType.FULL_CORE
):
# asciimaps is not smart about where the center should be, so we need to
# offset appropriately to get (0,0) in the middle
nx, ny = _getGridSize(asciimap.keys())
# turns out this works great for even and odd cases. love it when integer
# math works in your favor
iOffset = int(-nx / 2)
jOffset = int(-ny / 2)
for (i, j), spec in asciimap.items():
if spec == "-":
# skip placeholders
continue
self.gridContents[i + iOffset, j + jOffset] = spec
[docs] def getLocators(self, spatialGrid: grids.Grid, latticeIDs: list):
"""
Return spatialLocators in grid corresponding to lattice IDs.
This requires a fully-populated ``gridContents`` attribute.
"""
if latticeIDs is None:
return []
if self.gridContents is None:
return []
# tried using yamlize to coerce ints to strings but failed
# after much struggle, so we just auto-convert here to deal
# with int-like specifications.
# (yamlize.StrList fails to coerce when ints are provided)
latticeIDs = [str(i) for i in latticeIDs]
locators = []
for (i, j), spec in self.gridContents.items():
locator = spatialGrid[i, j, 0]
if spec in latticeIDs:
locators.append(locator)
return locators
[docs] def getMultiLocator(self, spatialGrid, latticeIDs):
"""Create a MultiIndexLocation based on lattice IDs."""
spatialLocator = grids.MultiIndexLocation(grid=spatialGrid)
spatialLocator.extend(self.getLocators(spatialGrid, latticeIDs))
return spatialLocator
[docs]class Grids(yamlize.KeyedList):
item_type = GridBlueprint
key_attr = GridBlueprint.name
def _getGridSize(idx) -> Tuple[int, int]:
"""
Return the number of spaces between the min and max of a collection of (int, int)
tuples, inclusive.
This essentially returns the number of grid locations along the i, and j dimesions,
given the (i,j) indices of each occupied location. This is useful for determining
certain grid offset behavior.
"""
nx = max(key[0] for key in idx) - min(key[0] for key in idx) + 1
ny = max(key[1] for key in idx) - min(key[1] for key in idx) + 1
return nx, ny
def _filterOutsideDomain(gridBp):
"""Remove grid contents that lie outside the represented domain.
This removes extra objects; ARMI allows the user input specifiers in regions outside
of the represented domain, which is fine as long as the contained specifier is
consistent with the corresponding region in the represented domain given the
symmetry condition. For instance, if we have a 1/3-core hex model, it is typically
okay for an assembly to be specified outside of the first 1/3rd of the core, as long
as it is the same assembly as would be there when expanding the first 1/3rd into a
full-core model.
However, we do not really want these hanging around, since editing the represented
1/Nth of the core will probably lead to consistency issues, so we remove them.
"""
grid = gridBp.construct()
contentsToRemove = {
idx
for idx, _contents in gridBp.gridContents.items()
if not grid.locatorInDomain(grid[idx + (0,)], symmetryOverlap=False)
}
for idx in contentsToRemove:
symmetrics = grid.getSymmetricEquivalents(idx)
for symmetric in symmetrics:
if symmetric in gridBp.gridContents:
if gridBp.gridContents[symmetric] != gridBp.gridContents[idx]:
raise ValueError(
"The contents at `{}` (`{}`) in grid `{}` is not the "
"same as it's symmetric equivalent at `{}` (`{}`). "
"Check your grid blueprints for symmetry.".format(
idx,
gridBp.gridContents[idx],
gridBp.name,
symmetric,
gridBp.gridContents[symmetric],
)
)
del gridBp.gridContents[idx]
[docs]def saveToStream(stream, bluep, full=False, tryMap=False):
"""
Save the blueprints to the passed stream.
This can save either the entire blueprints, or just the `grids:` section of the blueprints,
based on the passed ``full`` argument. Saving just the grid blueprints can be useful when
cobbling blueprints together with !include flags.
.. impl:: Write a blueprint file from a blueprint object.
:id: I_ARMI_BP_TO_DB
:implements: R_ARMI_BP_TO_DB
First makes a copy of the blueprints that are passed in. Then modifies any grids specified
in the blueprints into a canonical lattice map style, if needed. Then uses the ``dump``
method that is inherent to all ``yamlize`` subclasses to write the blueprints to the given
``stream`` object.
If called with the ``full`` argument, the entire blueprints is dumped. If not, only the
grids portion is dumped.
Parameters
----------
stream :
file output stream of some kind
bluep : armi.reactor.blueprints.Blueprints, or Grids
full : bool
Is this a full output file, or just a partial/grids?
tryMap : bool
regardless of input form, attempt to output as a lattice map
"""
# To save, we want to try our best to output our grid blueprints in the lattice
# map style. However, we do not want to wreck the state that the current
# blueprints are in. So we make a copy and do some manipulations to try to
# canonicalize it and save that, leaving the original blueprints unmolested.
bp = copy.deepcopy(bluep)
if isinstance(bp, blueprints.Blueprints):
gridDesigns = bp.gridDesigns
elif isinstance(bp, blueprints.Grids):
gridDesigns = bp
else:
raise TypeError("Expected Blueprints or Grids, got {}".format(type(bp)))
for gridDesignType, gridDesign in gridDesigns.items():
# The core equilibrium path should be put into the
# grid contents rather than a lattice map until we write
# a string-> tuple parser for reading it back in. Skip
# this type of grid.
if gridDesignType == "coreEqPath":
continue
_filterOutsideDomain(gridDesign)
if not gridDesign.gridContents:
# there is no grid, so there must be lattice, and that goes to output
continue
if gridDesign.readFromLatticeMap or tryMap:
symmetry = geometry.SymmetryType.fromStr(gridDesign.symmetry)
aMap = asciimaps.asciiMapFromGeomAndDomain(
gridDesign.geom, symmetry.domain
)()
aMap.asciiLabelByIndices = {
(key[0], key[1]): val for key, val in gridDesign.gridContents.items()
}
try:
aMap.gridContentsToAscii()
except Exception as e:
runLog.warning(
"The `lattice map` for the current assembly arrangement cannot be written. "
f"Defaulting to using the `grid contents` dictionary instead. Exception: {e}"
)
aMap = None
if aMap is not None:
# If there is an ascii map available then use it to fill out
# the contents of the lattice map section of the grid design.
# This also clears out the grid contents so there is not duplicate
# data.
gridDesign.gridContents = None
mapString = StringIO()
aMap.writeAscii(mapString)
gridDesign.latticeMap = scalarstring.LiteralScalarString(
mapString.getvalue()
)
else:
gridDesign.latticeMap = None
else:
# grid contents were supplied as a dictionary, so we shouldnt even have a
# latticeMap, unless it was set explicitly in code somewhere. Discard if
# there is one.
gridDesign.latticeMap = None
toSave = bp if full else gridDesigns
# NOTE: type(bp) here used because importing Blueprints causes a circular import
type(toSave).dump(toSave, stream)