# 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 blocks, which are axial chunks of assemblies. They contain
most of the state variables, including power, flux, and homogenized number densities.
Assemblies are made of blocks.
Blocks are made of components.
"""
from typing import Optional, Type, Tuple, ClassVar
import collections
import copy
import math
import numpy
from armi import nuclideBases
from armi import runLog
from armi.bookkeeping import report
from armi.physics.neutronics import GAMMA
from armi.physics.neutronics import NEUTRON
from armi.reactor import blockParameters
from armi.reactor import components
from armi.reactor import composites
from armi.reactor import geometry
from armi.reactor import grids
from armi.reactor import parameters
from armi.reactor.components import basicShapes
from armi.reactor.components.basicShapes import Hexagon, Circle
from armi.reactor.components.complexShapes import Helix
from armi.reactor.flags import Flags
from armi.reactor.parameters import ParamLocation
from armi.utils import densityTools
from armi.utils import hexagon
from armi.utils import units
from armi.utils.plotting import plotBlockFlux
from armi.utils.units import TRACE_NUMBER_DENSITY
PIN_COMPONENTS = [
Flags.CONTROL,
Flags.PLENUM,
Flags.SHIELD,
Flags.FUEL,
Flags.CLAD,
Flags.PIN,
Flags.WIRE,
]
_PitchDefiningComponent = Optional[Tuple[Type[components.Component], ...]]
[docs]class Block(composites.Composite):
"""
A homogenized axial slab of material.
Blocks are stacked together to form assemblies.
"""
uniqID = 0
# dimension used to determine which component defines the block's pitch
PITCH_DIMENSION = "op"
# component type that can be considered a candidate for providing pitch
PITCH_COMPONENT_TYPE: ClassVar[_PitchDefiningComponent] = None
pDefs = blockParameters.getBlockParameterDefinitions()
def __init__(self, name: str, height: float = 1.0):
"""
Builds a new ARMI block.
name : str
The name of this block
height : float, optional
The height of the block in cm. Defaults to 1.0 so that
`getVolume` assumes unit height.
"""
composites.Composite.__init__(self, name)
self.p.height = height
self.p.heightBOL = height
self.p.orientation = numpy.array((0.0, 0.0, 0.0))
self.points = []
self.macros = None
# flag to indicated when DerivedShape children must be updated.
self.derivedMustUpdate = False
# which component to use to determine block pitch, along with its 'op'
self._pitchDefiningComponent = (None, 0.0)
# TODO: what's causing these to have wrong values at BOL?
for problemParam in ["THcornTemp", "THedgeTemp"]:
self.p[problemParam] = []
for problemParam in [
"residence",
"bondRemoved",
"fluence",
"fastFluence",
"fastFluencePeak",
"displacementX",
"displacementY",
"fluxAdj",
"buRate",
"eqRegion",
"fissileFraction",
]:
self.p[problemParam] = 0.0
def __repr__(self):
# be warned, changing this might break unit tests on input file generations
return "<{type} {name} at {loc} XS: {xs} BU GP: {bu}>".format(
type=self.getType(),
name=self.getName(),
xs=self.p.xsType,
bu=self.p.buGroup,
loc=self.getLocation(),
)
def __deepcopy__(self, memo):
"""
Custom deepcopy behavior to prevent duplication of macros and _lumpedFissionProducts.
We detach the recursive links to the parent and the reactor to prevent blocks carrying large
independent copies of stale reactors in memory. If you make a new block, you must add it to
an assembly and a reactor.
"""
# add self to memo to prevent child objects from duplicating the parent block
memo[id(self)] = b = self.__class__.__new__(self.__class__)
# use __getstate__ and __setstate__ pickle-methods to initialize
state = self.__getstate__() # __getstate__ removes parent
del state["macros"]
del state["_lumpedFissionProducts"]
b.__setstate__(copy.deepcopy(state, memo))
# assign macros and LFP
b.macros = self.macros
b._lumpedFissionProducts = self._lumpedFissionProducts
return b
def _createHomogenizedCopy(self, pinSpatialLocators=False):
"""
Create a copy of a block.
Notes
-----
Used to implement a copy function for specific block types that can
be much faster than a deepcopy by glossing over details that may be
unnecessary in certain contexts.
This base class implementation is just a deepcopy of the block, in full detail
(not homogenized).
"""
return copy.deepcopy(self)
@property
def core(self):
from armi.reactor.reactors import Core
c = self.getAncestor(lambda c: isinstance(c, Core))
return c
@property
def r(self):
"""
Look through the ancestors of the Block to find a Reactor, and return it.
Notes
-----
Typical hierarchy: Reactor <- Core <- Assembly <- Block
A block should only have a reactor through a parent assembly.
It may make sense to try to factor out usage of ``b.r``.
Returns
-------
core.parent : armi.reactor.reactors.Reactor
ARMI reactor object that is an ancestor of the block.
Raises
------
ValueError
If the parent of the block's ``core`` is not an ``armi.reactor.reactors.Reactor``.
"""
from armi.reactor.reactors import Reactor
core = self.core
if core is None:
return self.getAncestor(lambda o: isinstance(o, Reactor))
if not isinstance(core.parent, Reactor):
raise TypeError(
"Parent of Block ({}) core is not a Reactor. Got {} instead".format(
core.parent, type(core.parent)
)
)
return core.parent
[docs] def makeName(self, assemNum, axialIndex):
"""
Generate a standard block from assembly number.
This also sets the block-level assembly-num param.
Once, we used a axial-character suffix to represent the axial
index, but this is inherently limited so we switched to a numerical
name. The axial suffix needs can be brought in in plugins that require
them.
Examples
--------
>>> makeName(120, 5)
'B0120-005'
"""
self.p.assemNum = assemNum
return "B{0:04d}-{1:03d}".format(assemNum, axialIndex)
[docs] def getSmearDensity(self, cold=True):
"""
Compute the smear density of pins in this block.
Smear density is the area of the fuel divided by the area of the space available
for fuel inside the cladding. Other space filled with solid materials is not
considered available. If all the area is fuel, it has 100% smear density. Lower
smear density allows more room for swelling.
.. warning:: This requires circular fuel and circular cladding. Designs that vary
from this will be wrong. It may make sense in the future to put this somewhere a
bit more design specific.
Notes
-----
This only considers circular objects. If you have a cladding that is not a circle,
it will be ignored.
Negative areas can exist for void gaps in the fuel pin. A negative area in a gap
represents overlap area between two solid components. To account for this
additional space within the pin cladding the abs(negativeArea) is added to the
inner cladding area.
Parameters
----------
cold : bool, optional
If false, returns the smear density at hot temperatures
Returns
-------
smearDensity : float
The smear density as a fraction
"""
fuels = self.getComponents(Flags.FUEL)
if not fuels:
return 0.0 # Smear density is not computed for non-fuel blocks
circles = self.getComponentsOfShape(components.Circle)
if not circles:
raise ValueError(
"Cannot get smear density of {}. There are no circular components.".format(
self
)
)
clads = set(self.getComponents(Flags.CLAD)).intersection(set(circles))
if not clads:
raise ValueError(
"Cannot get smear density of {}. There are no clad components.".format(
self
)
)
# Compute component areas
cladID = numpy.mean([clad.getDimension("id", cold=cold) for clad in clads])
innerCladdingArea = (
math.pi * (cladID**2) / 4.0 * self.getNumComponents(Flags.FUEL)
)
fuelComponentArea = 0.0
unmovableComponentArea = 0.0
negativeArea = 0.0
for c in self.getSortedComponentsInsideOfComponent(clads.pop()):
componentArea = c.getArea(cold=cold)
if c.isFuel():
fuelComponentArea += componentArea
elif c.hasFlags(Flags.SLUG):
# this flag designates that this clad/slug combination isn't fuel and shouldn't be counted in the average
pass
else:
if c.containsSolidMaterial():
unmovableComponentArea += componentArea
elif c.containsVoidMaterial() and componentArea < 0.0:
if cold: # will error out soon
runLog.error(
"{} with id {} and od {} has negative area at cold dimensions".format(
c,
c.getDimension("id", cold=True),
c.getDimension("od", cold=True),
)
)
negativeArea += abs(componentArea)
if cold and negativeArea:
raise ValueError(
"Negative component areas exist on {}. Check that the cold dimensions are properly aligned "
"and no components overlap.".format(self)
)
innerCladdingArea += negativeArea # See note 2
totalMovableArea = innerCladdingArea - unmovableComponentArea
smearDensity = fuelComponentArea / totalMovableArea
return smearDensity
[docs] def autoCreateSpatialGrids(self):
"""
Creates a spatialGrid for a Block.
Blocks do not always have a spatialGrid from Blueprints, but, some Blocks can have their
spatialGrids inferred based on the multiplicty of their components.
This would add the ability to create a spatialGrid for a Block and give its children
the corresponding spatialLocators if certain conditions are met.
Raises
------
ValueError
If the multiplicities of the block are not only 1 or N or if generated ringNumber leads to more positions than necessary.
"""
raise NotImplementedError()
[docs] def getMgFlux(self, adjoint=False, average=False, volume=None, gamma=False):
"""
Returns the multigroup neutron flux in [n/cm^2/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.
It is stored integrated over volume on self.p.mgFlux
Parameters
----------
adjoint : bool, optional
Return adjoint flux instead of real
average : bool, optional
If true, will return average flux between latest and previous. Doesn't work
for pin detailed yet
volume: float, optional
If average=True, the volume-integrated flux is divided by volume before being returned.
The user may specify a volume here, or the function will obtain the block volume directly.
gamma : bool, optional
Whether to return the neutron flux or the gamma flux.
Returns
-------
flux : multigroup neutron flux in [n/cm^2/s]
"""
flux = composites.ArmiObject.getMgFlux(
self, adjoint=adjoint, average=False, volume=volume, gamma=gamma
)
if average and numpy.any(self.p.lastMgFlux):
volume = volume or self.getVolume()
lastFlux = self.p.lastMgFlux / volume
flux = (flux + lastFlux) / 2.0
return flux
[docs] def setPinMgFluxes(self, fluxes, adjoint=False, gamma=False):
"""
Store the pin-detailed multi-group neutron flux.
The [g][i] indexing is transposed to be a list of lists, one for each pin. This makes it
simple to do depletion for each pin, etc.
Parameters
----------
fluxes : 2-D list of floats
The block-level pin multigroup fluxes. fluxes[g][i] represents the flux in group g for pin i.
Flux units are the standard n/cm^2/s.
The "ARMI pin ordering" is used, which is counter-clockwise from 3 o'clock.
adjoint : bool, optional
Whether to set real or adjoint data.
gamma : bool, optional
Whether to set gamma or neutron data.
Outputs
-------
self.p.pinMgFluxes : 2-D array of floats
The block-level pin multigroup fluxes. pinMgFluxes[g][i] represents the flux in group g for pin i.
Flux units are the standard n/cm^2/s.
The "ARMI pin ordering" is used, which is counter-clockwise from 3 o'clock.
"""
pinFluxes = []
G, nPins = fluxes.shape
for pinNum in range(1, nPins + 1):
thisPinFlux = []
if self.hasFlags(Flags.FUEL):
pinLoc = self.p.pinLocation[pinNum - 1]
else:
pinLoc = pinNum
for g in range(G):
thisPinFlux.append(fluxes[g][pinLoc - 1])
pinFluxes.append(thisPinFlux)
pinFluxes = numpy.array(pinFluxes)
if gamma:
if adjoint:
raise ValueError("Adjoint gamma flux is currently unsupported.")
else:
self.p.pinMgFluxesGamma = pinFluxes
else:
if adjoint:
self.p.pinMgFluxesAdj = pinFluxes
else:
self.p.pinMgFluxes = pinFluxes
[docs] def getMicroSuffix(self):
"""
Returns the microscopic library suffix (e.g. 'AB') for this block.
DIF3D and MC2 are limited to 6 character nuclide labels. ARMI by convention uses
the first 4 for nuclide name (e.g. U235, PU39, etc.) and then uses the 5th
character for cross-section type and the 6th for burnup group. This allows a
variety of XS sets to be built modeling substantially different blocks.
Notes
-----
The single-letter use for xsType and buGroup limit users to 26 groups of each.
ARMI will allow 2-letter xsType designations if and only if the `buGroups`
setting has length 1 (i.e. no burnup groups are defined). This is useful for
high-fidelity XS modeling of V&V models such as the ZPPRs.
"""
bu = self.p.buGroup
if not bu:
raise RuntimeError(
"Cannot get MicroXS suffix because {0} in {1} does not have a burnup group"
"".format(self, self.parent)
)
xsType = self.p.xsType
if len(xsType) == 1:
return xsType + bu
elif len(xsType) == 2 and ord(bu) > ord("A"):
raise ValueError(
"Use of multiple burnup groups is not allowed with multi-character xs groups!"
)
else:
return xsType
[docs] def getHeight(self):
"""Return the block height."""
return self.p.height
[docs] def setHeight(self, modifiedHeight, conserveMass=False, adjustList=None):
"""
Set a new height of the block.
Parameters
----------
modifiedHeight : float
The height of the block in cm
conserveMass : bool, optional
Conserve mass of nuclides in ``adjustList``.
adjustList : list, optional
Nuclides that will be conserved in conserving mass in the block. It is recommended to pass a list of
all nuclides in the block.
Notes
-----
There is a coupling between block heights, the parent assembly axial mesh,
and the ztop/zbottom/z params of the sibling blocks. When you set a height,
all those things are invalidated. Thus, this method has to go through and
update them via ``parent.calculateZCoords``. This could be inefficient
though it has not been identified as a bottleneck. Possible improvements
include deriving z/ztop/zbottom on the fly and invalidating the parent mesh
with some kind of flag, signaling it to recompute itself on demand.
Developers can get around some of the O(N^2) scaling of this by setting
``p.height`` directly but they must know to update the dependent objects
after they do that. Use with care.
See Also
--------
armi.reactor.reactors.Core.updateAxialMesh
May need to be called after this.
armi.reactor.assemblies.Assembly.calculateZCoords
Recalculates z-coords, automatically called by this.
"""
originalHeight = self.getHeight() # get before modifying
if modifiedHeight < 0.0:
raise ValueError(
"Cannot set height of block {} to height of {} cm".format(
self, modifiedHeight
)
)
self.p.height = modifiedHeight
self.clearCache()
if conserveMass:
if originalHeight != modifiedHeight:
if not adjustList:
raise ValueError(
"Nuclides in ``adjustList`` must be provided to conserve mass."
)
self.adjustDensity(originalHeight / modifiedHeight, adjustList)
if self.parent:
self.parent.calculateZCoords()
[docs] def getWettedPerimeter(self):
raise NotImplementedError
[docs] def getFlowAreaPerPin(self):
"""
Return the flowing coolant area of the block in cm^2, normalized to the number of pins in the block.
NumPins looks for max number of fuel, clad, control, etc.
See Also
--------
armi.reactor.blocks.Block.getNumPins
figures out numPins
"""
numPins = self.getNumPins()
try:
return self.getComponent(Flags.COOLANT, exact=True).getArea() / numPins
except ZeroDivisionError:
raise ZeroDivisionError(
"Block {} has 0 pins (fuel, clad, control, shield, etc.). Thus, its flow area "
"per pin is undefined.".format(self)
)
[docs] def getHydraulicDiameter(self):
raise NotImplementedError
[docs] def adjustUEnrich(self, newEnrich):
"""
Adjust U-235/U-238 mass ratio to a mass enrichment.
Parameters
----------
newEnrich : float
New U-235 enrichment in mass fraction
Notes
-----
completeInitialLoading must be run because adjusting the enrichment actually
changes the mass slightly and you can get negative burnups, which you do not want.
"""
fuels = self.getChildrenWithFlags(Flags.FUEL)
if fuels:
for fuel in fuels:
fuel.adjustMassEnrichment(newEnrich)
else:
# no fuel in this block
tU = self.getNumberDensity("U235") + self.getNumberDensity("U238")
if tU:
self.setNumberDensity("U235", tU * newEnrich)
self.setNumberDensity("U238", tU * (1.0 - newEnrich))
self.completeInitialLoading()
[docs] def getLocation(self):
"""Return a string representation of the location."""
if self.core and self.parent.spatialGrid and self.spatialLocator:
return self.core.spatialGrid.getLabel(
self.spatialLocator.getCompleteIndices()
)
else:
return "ExCore"
[docs] def coords(self, rotationDegreesCCW=0.0):
if rotationDegreesCCW:
raise NotImplementedError("Cannot get coordinates with rotation.")
return self.spatialLocator.getGlobalCoordinates()
[docs] def setBuLimitInfo(self):
r"""Sets burnup limit based on igniter, feed, etc."""
if self.p.buRate == 0:
# might be cycle 1 or a non-burning block
self.p.timeToLimit = 0.0
else:
timeLimit = (
self.p.buLimit - self.p.percentBu
) / self.p.buRate + self.p.residence
self.p.timeToLimit = (timeLimit - self.p.residence) / units.DAYS_PER_YEAR
[docs] def getMaxArea(self):
raise NotImplementedError
[docs] def getMaxVolume(self):
"""
The maximum volume of this object if it were totally full.
Returns
-------
vol : float
volume in cm^3.
"""
return self.getMaxArea() * self.getHeight()
[docs] def getArea(self, cold=False):
"""
Return the area of a block for a full core or a 1/3 core model.
Area is consistent with the area in the model, so if you have a central
assembly in a 1/3 symmetric model, this will return 1/3 of the total
area of the physical assembly. This way, if you take the sum
of the areas in the core (or count the atoms in the core, etc.),
you will have the proper number after multiplying by the model symmetry.
Parameters
----------
cold : bool
flag to indicate that cold (as input) dimensions are required
Notes
-----
This might not work for a 1/6 core model (due to symmetry line issues).
Returns
-------
area : float (cm^2)
See Also
--------
armi.reactor.blocks.Block.getMaxArea
return the full area of the physical assembly disregarding model symmetry
"""
# this caching requires that you clear the cache every time you adjust anything
# including temperature and dimensions.
area = self._getCached("area")
if area:
return area
a = 0.0
for c in self.getChildren():
myArea = c.getArea(cold=cold)
a += myArea
fullArea = a
# correct the fullHexArea by the symmetry factor
# this factor determines if the hex has been clipped by symmetry lines
area = fullArea / self.getSymmetryFactor()
self._setCache("area", area)
return area
[docs] def getVolume(self):
"""
Return the volume of a block.
Returns
-------
volume : float
Block or component volume in cm^3
"""
# use symmetryFactor in case the assembly is sitting on a boundary and needs to be cut in half, etc.
vol = sum(c.getVolume() for c in self)
return vol / self.getSymmetryFactor()
[docs] def getSymmetryFactor(self):
"""
Return a scaling factor due to symmetry on the area of the block or its components.
Takes into account assemblies that are bisected or trisected by symmetry lines
In 1/3 symmetric cases, the central assembly is 1/3 a full area.
If edge assemblies are included in a model, the symmetry factor along
both edges for overhanging assemblies should be 2.0. However,
ARMI runs in most scenarios with those assemblies on the 120-edge removed,
so the symmetry factor should generally be just 1.0.
See Also
--------
armi.reactor.converters.geometryConverter.EdgeAssemblyChanger.scaleParamsRelatedToSymmetry
"""
return 1.0
[docs] def isOnWhichSymmetryLine(self):
"""Block symmetry lines are determined by the reactor, not the parent."""
grid = self.core.spatialGrid
return grid.overlapsWhichSymmetryLine(self.spatialLocator.getCompleteIndices())
[docs] def adjustDensity(self, frac, adjustList, returnMass=False):
"""
adjusts the total density of each nuclide in adjustList by frac.
Parameters
----------
frac : float
The fraction of the current density that will remain after this operation
adjustList : list
List of nuclide names that will be adjusted.
returnMass : bool
If true, will return mass difference.
Returns
-------
mass : float
Mass difference in grams. If you subtract mass, mass will be negative.
If returnMass is False (default), this will always be zero.
"""
self._updateDetailedNdens(frac, adjustList)
mass = 0.0
if returnMass:
# do this with a flag to enable faster operation when mass is not needed.
volume = self.getVolume()
numDensities = self.getNuclideNumberDensities(adjustList)
for nuclideName, dens in zip(adjustList, numDensities):
if not dens:
# don't modify zeros.
continue
newDens = dens * frac
# add a little so components remember
self.setNumberDensity(nuclideName, newDens + TRACE_NUMBER_DENSITY)
if returnMass:
mass += densityTools.getMassInGrams(nuclideName, volume, newDens - dens)
return mass
def _updateDetailedNdens(self, frac, adjustList):
"""
Update detailed number density which is used by hi-fi depleters such as ORIGEN.
Notes
-----
This will perturb all number densities so it is assumed that if one of the active densities
is perturbed, all of htem are perturbed.
"""
if self.p.detailedNDens is None:
# BOL assems get expanded to a reference so the first check is needed so it
# won't call .blueprints on None since BOL assems don't have a core/r
return
if any(nuc in self.r.blueprints.activeNuclides for nuc in adjustList):
self.p.detailedNDens *= frac
# Other power densities do not need to be updated as they are calculated in
# the global flux interface, which occurs after axial expansion from crucible
# on the interface stack.
self.p.pdensDecay *= frac
[docs] def completeInitialLoading(self, bolBlock=None):
"""
Does some BOL bookkeeping to track things like BOL HM density for burnup tracking.
This should run after this block is loaded up at BOC (called from
Reactor.initialLoading).
The original purpose of this was to get the moles HM at BOC for the moles
Pu/moles HM at BOL calculation.
This also must be called after modifying something like the smear density or zr
fraction in an optimization case. In ECPT cases, a BOL block must be passed or
else the burnup will try to get based on a pre-burned value.
Parameters
----------
bolBlock : Block, optional
A BOL-state block of this block type, required for perturbed equilibrium cases.
Must have the same enrichment as this block!
Returns
-------
hmDens : float
The heavy metal number density of this block.
See Also
--------
Reactor.importGeom
depletion._updateBlockParametersAfterDepletion
"""
if bolBlock is None:
bolBlock = self
hmDens = bolBlock.getHMDens() # total homogenized heavy metal number density
self.p.nHMAtBOL = hmDens
self.p.molesHmBOL = self.getHMMoles()
self.p.puFrac = (
self.getPuMoles() / self.p.molesHmBOL if self.p.molesHmBOL > 0.0 else 0.0
)
try:
# non-pinned reactors (or ones without cladding) will not use smear density
self.p.smearDensity = self.getSmearDensity()
except ValueError:
pass
self.p.enrichmentBOL = self.getFissileMassEnrich()
massHmBOL = 0.0
sf = self.getSymmetryFactor()
for child in self:
hmMass = child.getHMMass() * sf
massHmBOL += hmMass
# Components have a massHmBOL parameter but not every composite will
if isinstance(child, components.Component):
child.p.massHmBOL = hmMass
self.p.massHmBOL = massHmBOL
return hmDens
[docs] def setB10VolParam(self, heightHot):
"""
Set the b.p.initialB10ComponentVol param according to the volume of boron-10 containing components.
Parameters
----------
heightHot : Boolean
True if self.height() is cold height
"""
# exclude fuel components since they could have slight B10 impurity and
# this metric is not relevant for fuel.
b10Comps = [c for c in self if c.getNumberDensity("B10") and not c.isFuel()]
if not b10Comps:
return
# get the highest density comp dont want to sum all because some
# comps might have very small impurities of boron and adding this
# volume wont be conservative for captures per cc.
b10Comp = sorted(b10Comps, key=lambda x: x.getNumberDensity("B10"))[-1]
if len(b10Comps) > 1:
runLog.warning(
f"More than one boron10-containing component found in {self.name}. "
f"Only {b10Comp} will be considered for calculation of initialB10ComponentVol "
"Since adding multiple volumes is not conservative for captures/cc."
f"All compos found {b10Comps}",
single=True,
)
if self.isFuel():
runLog.warning(
f"{self.name} has both fuel and initial b10. "
"b10 volume may not be conserved with axial expansion.",
single=True,
)
# calc volume of boron components
coldArea = b10Comp.getArea(cold=True)
coldFactor = b10Comp.getThermalExpansionFactor() if heightHot else 1
coldHeight = self.getHeight() / coldFactor
self.p.initialB10ComponentVol = coldArea * coldHeight
[docs] def replaceBlockWithBlock(self, bReplacement):
"""
Replace the current block with the replacementBlock.
Typically used in the insertion of control rods.
"""
paramsToSkip = set(
self.p.paramDefs.inCategory(parameters.Category.retainOnReplacement).names
)
tempBlock = copy.deepcopy(bReplacement)
oldParams = self.p
newParams = self.p = tempBlock.p
for paramName in paramsToSkip:
newParams[paramName] = oldParams[paramName]
# update synchronization information
self.p.assigned = parameters.SINCE_ANYTHING
paramDefs = self.p.paramDefs
for paramName in set(newParams.keys()) - paramsToSkip:
paramDefs[paramName].assigned = parameters.SINCE_ANYTHING
newComponents = tempBlock.getChildren()
self.setChildren(newComponents)
self.clearCache()
[docs] @staticmethod
def plotFlux(core, fName=None, bList=None, peak=False, adjoint=False, bList2=[]):
# Block.plotFlux has been moved to utils.plotting as plotBlockFlux, which is a
# better fit.
# We don't want to remove the plotFlux function in the Block namespace yet
# in case client code is depending on this function existing here. This is just
# a simple pass-through function that passes the arguments along to the actual
# implementation in its new location.
plotBlockFlux(core, fName, bList, peak, adjoint, bList2)
def _updatePitchComponent(self, c):
"""
Update the component that defines the pitch.
Given a Component, compare it to the current component that defines the pitch of the Block.
If bigger, replace it.
We need different implementations of this to support different logic for determining the
form of pitch and the concept of "larger".
See Also
--------
CartesianBlock._updatePitchComponent
"""
# Some block types don't have a clearly defined pitch (e.g. ThRZ)
if self.PITCH_COMPONENT_TYPE is None:
return
if not isinstance(c, self.PITCH_COMPONENT_TYPE):
return
try:
componentPitch = c.getDimension(self.PITCH_DIMENSION)
except parameters.UnknownParameterError:
# some components dont have the appropriate parameter
return
if componentPitch and (componentPitch > self._pitchDefiningComponent[1]):
self._pitchDefiningComponent = (c, componentPitch)
[docs] def add(self, c):
composites.Composite.add(self, c)
self.derivedMustUpdate = True
self.clearCache()
try:
mult = int(c.getDimension("mult"))
if self.p.percentBuByPin is None or len(self.p.percentBuByPin) < mult:
# this may be a little wasteful, but we can fix it later...
self.p.percentBuByPin = [0.0] * mult
except AttributeError:
# maybe adding a Composite of components rather than a single
pass
self._updatePitchComponent(c)
[docs] def removeAll(self, recomputeAreaFractions=True):
for c in self.getChildren():
self.remove(c, recomputeAreaFractions=False)
if recomputeAreaFractions: # only do this once
self.getVolumeFractions()
[docs] def remove(self, c, recomputeAreaFractions=True):
composites.Composite.remove(self, c)
self.clearCache()
if c is self._pitchDefiningComponent[0]:
self._pitchDefiningComponent = (None, 0.0)
pc = self.getLargestComponent(self.PITCH_DIMENSION)
if pc is not None:
self._updatePitchComponent(pc)
if recomputeAreaFractions:
self.getVolumeFractions()
[docs] def getComponentsThatAreLinkedTo(self, comp, dim):
"""
Determine which dimensions of which components are linked to a specific dimension of a particular component.
Useful for breaking fuel components up into individuals and making sure
anything that was linked to the fuel mult (like the cladding mult) stays correct.
Parameters
----------
comp : Component
The component that the results are linked to
dim : str
The name of the dimension that the results are linked to
Returns
-------
linkedComps : list
A list of (components,dimName) that are linked to this component, dim.
"""
linked = []
for c in self.iterComponents():
for dimName, val in c.p.items():
if c.dimensionIsLinked(dimName):
requiredComponent = val[0]
if requiredComponent is comp and val[1] == dim:
linked.append((c, dimName))
return linked
[docs] def getComponentsInLinkedOrder(self, componentList=None):
"""
Return a list of the components in order of their linked-dimension dependencies.
Parameters
----------
components : list, optional
A list of components to consider. If None, this block's components will be used.
Notes
-----
This means that components other components are linked to come first.
"""
if componentList is None:
componentList = self.getComponents()
cList = collections.deque(componentList)
orderedComponents = []
# Loop through the components until there are none left.
counter = 0
while cList:
candidate = cList.popleft() # take first item in list
cleared = True # innocent until proven guilty
# loop through all dimensions in this component to determine its dependencies
for dimName, val in candidate.p.items():
if candidate.dimensionIsLinked(dimName):
# In linked dimensions, val = (component, dimName)
requiredComponent = val[0]
if requiredComponent not in orderedComponents:
# this component depends on one that is not in the ordered list yet.
# do not add it.
cleared = False
break # short circuit. One failed lookup is enough to flag this component as dirty.
if cleared:
# this candidate is free of dependencies and is ready to be added.
orderedComponents.append(candidate)
else:
cList.append(candidate)
counter += 1
if counter > 1000:
cList.append(candidate)
runLog.error(
"The component {0} in {1} contains a dimension that is linked to another component, "
" but the required component is not present in the block. They may also be other dependency fails. "
"The component dims are {2}".format(cList[0], self, cList[0].p)
)
raise RuntimeError("Cannot locate linked component.")
return orderedComponents
[docs] def getSortedComponentsInsideOfComponent(self, component):
"""
Returns a list of components inside of the given component sorted from innermost to outermost.
Parameters
----------
component : object
Component to look inside of.
Notes
-----
If you just want sorted components in this block, use ``sorted(self)``.
This will never include any ``DerivedShape`` objects. Since they have a derived
area they don't have a well-defined dimension. For now we just ignore them.
If they are desired in the future some knowledge of their dimension will be
required while they are being derived.
"""
sortedComponents = sorted(self)
componentIndex = sortedComponents.index(component)
sortedComponents = sortedComponents[:componentIndex]
return sortedComponents
[docs] def getNumPins(self):
"""Return the number of pins in this block."""
nPins = [
sum(
[
int(c.getDimension("mult"))
if isinstance(c, basicShapes.Circle)
else 0
for c in self.iterComponents(compType)
]
)
for compType in PIN_COMPONENTS
]
return 0 if not nPins else max(nPins)
[docs] def mergeWithBlock(self, otherBlock, fraction):
"""
Turns this block into a mixture of this block and some other block.
Parameters
----------
otherBlock : Block
The block to mix this block with. The other block will not be modified.
fraction : float
Fraction of the other block to mix in with this block. If 0.1 is passed in, this block
will become 90% what it originally was and 10% what the other block is.
Notes
-----
This merges on a high level (using number densities). Components will not be merged.
This is used e.g. for inserting a control block partially to get a very tight criticality
control. In this case, a control block would be merged with a duct block. It is also used
when a control rod is specified as a certain length but that length does not fit exactly
into a full block.
"""
numDensities = self.getNumberDensities()
otherBlockDensities = otherBlock.getNumberDensities()
newDensities = {}
# Make sure to hit all nuclides in union of blocks
for nucName in set(numDensities.keys()).union(otherBlockDensities.keys()):
newDensities[nucName] = (1.0 - fraction) * numDensities.get(
nucName, 0.0
) + fraction * otherBlockDensities.get(nucName, 0.0)
self.setNumberDensities(newDensities)
[docs] def getComponentAreaFrac(self, typeSpec):
"""
Returns the area fraction of the specified component(s) among all components in the block.
Parameters
----------
typeSpec : Flags or list of Flags
Component types to look up
Examples
--------
>>> b.getComponentAreaFrac(Flags.CLAD)
0.15
Returns
-------
float
The area fraction of the component.
"""
tFrac = sum(f for (c, f) in self.getVolumeFractions() if c.hasFlags(typeSpec))
if tFrac:
return tFrac
else:
runLog.warning(
"No component {0} exists on {1}, so area fraction is zero.".format(
typeSpec, self
),
single=True,
label="{0} areaFrac is zero".format(typeSpec),
)
return 0.0
[docs] def verifyBlockDims(self):
"""Optional dimension checking."""
return
[docs] def getDim(self, typeSpec, dimName):
"""
Search through blocks in this assembly and find the first component of compName.
Then, look on that component for dimName.
Parameters
----------
typeSpec : Flags or list of Flags
Component name, e.g. Flags.FUEL, Flags.CLAD, Flags.COOLANT, ...
dimName : str
Dimension name, e.g. 'od', ...
Returns
-------
dimVal : float
The dimension in cm.
Examples
--------
>>> getDim(Flags.WIRE,'od')
0.01
"""
for c in self:
if c.hasFlags(typeSpec):
return c.getDimension(dimName.lower())
raise ValueError(
"Cannot get Dimension because Flag not found: {0}".format(typeSpec)
)
[docs] def getPinCenterFlatToFlat(self, cold=False):
"""Return the flat-to-flat distance between the centers of opposing pins in the outermost ring."""
raise NotImplementedError # no geometry can be assumed
[docs] def getWireWrapCladGap(self, cold=False):
"""Return the gap betwen the wire wrap and the clad."""
clad = self.getComponent(Flags.CLAD)
wire = self.getComponent(Flags.WIRE)
wireOuterRadius = wire.getBoundingCircleOuterDiameter(cold=cold) / 2.0
wireInnerRadius = wireOuterRadius - wire.getDimension("od", cold=cold)
cladOuterRadius = clad.getDimension("od", cold=cold) / 2.0
return wireInnerRadius - cladOuterRadius
[docs] def getPlenumPin(self):
"""Return the plenum pin if it exists."""
for c in self.iterComponents(Flags.GAP):
if self.isPlenumPin(c):
return c
return None
[docs] def isPlenumPin(self, c):
"""Return True if the specified component is a plenum pin."""
# This assumes that anything with the GAP flag will have a valid 'id' dimension. If that
# were not the case, then we would need to protect the call to getDimension with a
# try/except
cIsCenterGapGap = (
isinstance(c, components.Component)
and c.hasFlags(Flags.GAP)
and c.getDimension("id") == 0
)
if self.hasFlags([Flags.PLENUM, Flags.ACLP]) and cIsCenterGapGap:
return True
return False
[docs] def getPitch(self, returnComp=False):
"""
Return the center-to-center hex pitch of this block.
Parameters
----------
returnComp : bool, optional
If true, will return the component that has the maximum pitch as well
Returns
-------
pitch : float or None
Hex pitch in cm, if well-defined. If there is no clear component for determining pitch,
returns None
component : Component or None
Component that has the max pitch, if returnComp == True. If no component is found to
define the pitch, returns None
Notes
-----
The block stores a reference to the component that defines the pitch, making the assumption
that while the dimensions can change, the component containing the largest dimension will
not. This lets us skip the search for largest component. We still need to ask the largest
component for its current dimension in case its temperature changed, or was otherwise
modified.
See Also
--------
setPitch : sets pitch
"""
c, _p = self._pitchDefiningComponent
if c is None:
raise ValueError("{} has no valid pitch defining component".format(self))
# ask component for dimensions, since they could have changed,
# due to temperature, for example.
p = c.getPitchData()
return (p, c) if returnComp else p
[docs] def hasPinPitch(self):
"""Return True if the block has enough information to calculate pin pitch."""
return self.spatialGrid is not None
[docs] def getPinPitch(self, cold=False):
"""
Return sub-block pitch in blocks.
This assumes the spatial grid is defined by unit steps
"""
return self.spatialGrid.pitch
[docs] def getDimensions(self, dimension):
"""Return dimensional values of the specified dimension."""
dimVals = set()
for c in self.getChildren():
try:
dimVal = c.getDimension(dimension)
except parameters.ParameterError:
continue
if dimVal is not None:
dimVals.add(dimVal)
return dimVals
[docs] def getLargestComponent(self, dimension):
"""
Find the component with the largest dimension of the specified type.
Parameters
----------
dimension: str
The name of the dimension to find the largest component of.
Returns
-------
largestComponent: armi.reactor.components.Component
The component with the largest dimension of the specified type.
"""
maxDim = -float("inf")
largestComponent = None
for c in self:
try:
dimVal = c.getDimension(dimension)
except parameters.ParameterError:
continue
if dimVal is not None and dimVal > maxDim:
maxDim = dimVal
largestComponent = c
return largestComponent
[docs] def setPitch(self, val, updateBolParams=False):
"""
Sets outer pitch to some new value.
This sets the settingPitch and actually sets the dimension of the outer hexagon.
During a load (importGeom), the setDimension doesn't usually do anything except
set the setting See Issue 034
But during a actual case modification (e.g. in an optimization sweep, then the dimension
has to be set as well.
See Also
--------
getPitch : gets the pitch
"""
c, _p = self._pitchDefiningComponent
if c:
c.setDimension("op", val)
self._pitchDefiningComponent = (c, val)
else:
raise RuntimeError("No pitch-defining component on block {}".format(self))
if updateBolParams:
self.completeInitialLoading()
[docs] def getMfp(self, gamma=False):
r"""
Calculate the mean free path for neutron or gammas in this block.
.. math::
<\Sigma> = \frac{\sum_E(\phi_e \Sigma_e dE)}{\sum_E (\phi_e dE)} =
\frac{\sum_E(\phi_e N \sum_{\text{type}}(\sigma_e) dE}{\sum_E (\phi_e dE))}
Block macro is the sum of macros of all nuclides.
phi_g = flux*dE already in multigroup method.
Returns
-------
mfp, mfpAbs, diffusionLength : tuple(float, float float)
"""
lib = self.core.lib
flux = self.getMgFlux(gamma=gamma)
flux = [fi / max(flux) for fi in flux]
mfpNumerator = numpy.zeros(len(flux))
absMfpNumerator = numpy.zeros(len(flux))
transportNumerator = numpy.zeros(len(flux))
numDensities = self.getNumberDensities()
# vol = self.getVolume()
for nucName, nDen in numDensities.items():
nucMc = nuclideBases.byName[nucName].label + self.getMicroSuffix()
if gamma:
micros = lib[nucMc].gammaXS
else:
micros = lib[nucMc].micros
total = micros.total[:, 0] # 0th order
transport = micros.transport[:, 0] # 0th order, [bn]
absorb = sum(micros.getAbsorptionXS())
mfpNumerator += nDen * total # [cm]
absMfpNumerator += nDen * absorb
transportNumerator += nDen * transport
denom = sum(flux)
mfp = 1.0 / (sum(mfpNumerator * flux) / denom)
sigmaA = sum(absMfpNumerator * flux) / denom
sigmaTr = sum(transportNumerator * flux) / denom
diffusionCoeff = 1 / (3.0 * sigmaTr)
mfpAbs = 1 / sigmaA
diffusionLength = math.sqrt(diffusionCoeff / sigmaA)
return mfp, mfpAbs, diffusionLength
[docs] def setAreaFractionsReport(self):
for c, frac in self.getVolumeFractions():
report.setData(
c.getName(),
["{0:10f}".format(c.getArea()), "{0:10f}".format(frac)],
report.BLOCK_AREA_FRACS,
)
# return the group the information went to
return report.ALL[report.BLOCK_AREA_FRACS]
[docs] def getBlocks(self):
"""
This method returns all the block(s) included in this block
its implemented so that methods could iterate over reactors, assemblies
or single blocks without checking to see what the type of the
reactor-family object is.
"""
return [self]
[docs] def updateComponentDims(self):
"""
This method updates all the dimensions of the components.
Notes
-----
This is VERY useful for defining a ThRZ core out of
differentialRadialSegements whose dimensions are connected together
some of these dimensions are derivative and can be updated by changing
dimensions in a Parameter Component or other linked components
See Also
--------
armi.reactor.components.DifferentialRadialSegment.updateDims
armi.reactor.components.Parameters
armi.physics.optimize.OptimizationInterface.modifyCase (look up 'ThRZReflectorThickness')
"""
for c in self.getComponentsInLinkedOrder():
try:
c.updateDims()
except NotImplementedError:
runLog.warning("{0} has no updatedDims method -- skipping".format(c))
[docs] def breakFuelComponentsIntoIndividuals(self):
"""
Split block-level components (in fuel blocks) into pin-level components.
The fuel component will be broken up according to its multiplicity.
Order matters! The first pin component will be located at a particular (x, y), which
will be used in the fluxRecon module to determine the interpolated flux.
The fuel will become fuel001 through fuel169 if there are 169 pins.
"""
fuels = self.getChildrenWithFlags(Flags.FUEL)
if len(fuels) != 1:
runLog.error(
"This block contains {0} fuel components: {1}".format(len(fuels), fuels)
)
raise RuntimeError(
"Cannot break {0} into multiple fuel components b/c there is not a single fuel"
" component.".format(self)
)
fuel = fuels[0]
fuelFlags = fuel.p.flags
nPins = self.getNumPins()
runLog.info(
"Creating {} individual {} components on {}".format(nPins, fuel, self)
)
# Handle all other components that may be linked to the fuel multiplicity
# by unlinking them and setting them directly.
# TODO: What about other (actual) dimensions? This is a limitation in that only fuel
# compuents are duplicated, and not the entire pin. It is also a reasonable assumption with
# current/historical usage of ARMI.
for comp, dim in self.getComponentsThatAreLinkedTo(fuel, "mult"):
comp.setDimension(dim, nPins)
# finish the first pin as a single pin
fuel.setDimension("mult", 1)
fuel.setName("fuel001")
fuel.p.pinNum = 1
# create all the new pin components and add them to the block with 'fuel001' names
for i in range(nPins - 1):
# wow, only use of a non-deepcopy
newC = copy.copy(fuel)
newC.setName("fuel{0:03d}".format(i + 2)) # start with 002.
newC.p.pinNum = i + 2
self.add(newC)
# update moles at BOL for each pin
self.p.molesHmBOLByPin = []
for pin in self.iterComponents(Flags.FUEL):
# Update the fuel component flags to be the same as before the split (i.e., DEPLETABLE)
pin.p.flags = fuelFlags
self.p.molesHmBOLByPin.append(pin.getHMMoles())
pin.p.massHmBOL /= nPins
[docs] def getIntegratedMgFlux(self, adjoint=False, gamma=False):
"""
Return the volume integrated 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 : numpy.array
multigroup neutron tracklength in [n-cm/s]
"""
if adjoint:
if gamma:
raise ValueError("Adjoint gamma flux is currently unsupported.")
integratedFlux = self.p.adjMgFlux
elif gamma:
integratedFlux = self.p.mgFluxGamma
else:
integratedFlux = self.p.mgFlux
return numpy.array(integratedFlux)
[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
"""
return composites.ArmiObject.getLumpedFissionProductCollection(self)
[docs] def rotate(self, rad):
"""Function for rotating a block's spatially varying variables by a specified angle (radians).
Parameters
----------
rad - float
number (in radians) specifying the angle of counter clockwise rotation
"""
raise NotImplementedError
[docs] def setAxialExpTargetComp(self, targetComponent):
"""Sets the targetComponent for the axial expansion changer.
Parameter
---------
targetComponent: :py:class:`Component <armi.reactor.components.component.Component>` object
component specified to be target component for axial expansion changer
See Also
--------
armi.reactor.converters.axialExpansionChanger.py::ExpansionData::_setTargetComponents
"""
self.p.axialExpTargetComponent = targetComponent.name
[docs] def getPinCoordinates(self):
"""
Compute the local centroid coordinates of any pins in this block.
The pins must have a CLAD-flagged component for this to work.
Returns
-------
localCoordinates : list
list of (x,y,z) pairs representing each pin in the order they are listed as children
Notes
-----
This assumes hexagonal pin lattice and needs to be upgraded once more generic geometry
options are needed. Only works if pins have clad.
"""
coords = []
for clad in self.getChildrenWithFlags(Flags.CLAD):
if isinstance(clad.spatialLocator, grids.MultiIndexLocation):
coords.extend(
[locator.getLocalCoordinates() for locator in clad.spatialLocator]
)
else:
coords.append(clad.spatialLocator.getLocalCoordinates())
return coords
[docs]class HexBlock(Block):
PITCH_COMPONENT_TYPE: ClassVar[_PitchDefiningComponent] = (components.Hexagon,)
def __init__(self, name, height=1.0):
Block.__init__(self, name, height)
[docs] def coords(self, rotationDegreesCCW=0.0):
x, y, _z = self.spatialLocator.getGlobalCoordinates()
x += self.p.displacementX * 100.0
y += self.p.displacementY * 100.0
return (
round(x, units.FLOAT_DIMENSION_DECIMALS),
round(y, units.FLOAT_DIMENSION_DECIMALS),
)
def _createHomogenizedCopy(self, pinSpatialLocators=False):
"""
Create a new homogenized copy of a block that is less expensive than a full deepcopy.
Notes
-----
This can be used to improve performance when a new copy of a reactor needs to be
built, but the full detail of the block (including component geometry, material,
number density, etc.) is not required for the targeted physics solver being applied
to the new reactor model.
The main use case is for the uniform mesh converter (UMC). Frequently, a deterministic
neutronics solver will require a uniform mesh reactor, which is produced by the UMC.
Many deterministic solvers for fast spectrum reactors will also treat the individual
blocks as homogenized mixtures. Since the neutronics solver does not need to know about
the geometric and material details of the individual child components within a block,
we can save significant effort while building the uniform mesh reactor with the UMC
by omitting this detailed data and only providing the necessary level of detail for
the uniform mesh reactor: number densities on each block.
.. note: Individual components within a block can have different temperatures, and this
can affect cross sections. This temperature variation is captured by the lattice physics
module. As long as temperature distribution is correctly captured during cross section
generation, it doesn't need to be transferred to the neutronics solver directly through
this copy operation.
.. note: If you make a new block, you must add it to an assembly and a reactor.
See Also
--------
armi.reactor.converters.uniformMesh.UniformMeshGeometryConverter.makeAssemWithUniformMesh
"""
b = self.__class__(self.getName(), height=self.getHeight())
b.setType(self.getType(), self.p.flags)
# assign macros and LFP
b.macros = self.macros
b._lumpedFissionProducts = self._lumpedFissionProducts
b.p.buGroup = self.p.buGroup
hexComponent = Hexagon(
"homogenizedHex",
"_Mixture",
self.getAverageTempInC(),
self.getAverageTempInC(),
self._pitchDefiningComponent[1],
)
hexComponent.setNumberDensities(self.getNumberDensities())
b.add(hexComponent)
b.p.nPins = self.p.nPins
if pinSpatialLocators:
# create a null component with cladding flags and spatialLocator from source block's
# clad components in case pin locations need to be known for physics solver
if self.hasComponents(Flags.CLAD):
cladComponents = self.getComponents(Flags.CLAD)
for i, clad in enumerate(cladComponents):
pinComponent = Circle(
f"voidPin{i}",
"Void",
self.getAverageTempInC(),
self.getAverageTempInC(),
0.0,
)
pinComponent.setType("pin", Flags.CLAD)
pinComponent.spatialLocator = copy.deepcopy(clad.spatialLocator)
if isinstance(
pinComponent.spatialLocator, grids.MultiIndexLocation
):
for i1, i2 in zip(
list(pinComponent.spatialLocator), list(clad.spatialLocator)
):
i1.associate(i2.grid)
pinComponent.setDimension("mult", clad.getDimension("mult"))
b.add(pinComponent)
if self.spatialGrid is not None:
b.spatialGrid = self.spatialGrid
return b
[docs] def getMaxArea(self):
"""Compute the max area of this block if it was totally full."""
pitch = self.getPitch()
if not pitch:
return 0.0
return hexagon.area(pitch)
[docs] def getDuctIP(self):
duct = self.getComponent(Flags.DUCT, exact=True)
return duct.getDimension("ip")
[docs] def getDuctOP(self):
duct = self.getComponent(Flags.DUCT, exact=True)
return duct.getDimension("op")
[docs] def initializePinLocations(self):
nPins = self.getNumPins()
self.p.pinLocation = list(range(1, nPins + 1))
[docs] def setPinPowers(self, powers, powerKeySuffix=""):
"""
Updates the pin linear power densities of this block for the current rotation.
The linear densities are represented by the *linPowByPin* parameter.
It is assumed that :py:meth:`.initializePinLocations` has already been executed
for fueled blocks in order to access the *pinLocation* parameter. The
*pinLocation* parameter is not accessed for non-fueled blocks.
The *linPowByPin* parameter can be directly assigned to instead of using this
method if the multiplicity of the pins in the block is equal to the number of
pins in the block.
Parameters
----------
powers : list of floats, required
The block-level pin linear power densities. powers[i] represents the average
linear power density of pin i. The units of linear power density is watts/cm
(i.e., watts produced per cm of pin length). The "ARMI pin ordering" must be
be used, which is counter-clockwise from 3 o'clock.
powerKeySuffix: str, optional
Must be either an empty string, :py:const:`NEUTRON <armi.physics.neutronics.const.NEUTRON>`,
or :py:const:`GAMMA <armi.physics.neutronics.const.GAMMA>`. Defaults to empty
string.
Notes
-----
This method can handle assembly rotations by using the *pinLocation* parameter.
"""
numPins = self.getNumPins()
if not numPins or numPins != len(powers):
raise ValueError(
f"Invalid power data for {self} with {numPins} pins."
f" Got {len(powers)} entries in powers: {powers}"
)
powerKey = f"linPowByPin{powerKeySuffix}"
self.p[powerKey] = numpy.zeros(numPins)
# Loop through rings. The *pinLocation* parameter is only accessed for fueled
# blocks; it is assumed that non-fueled blocks do not use a rotation map.
for pinNum in range(numPins):
if self.hasFlags(Flags.FUEL):
# -1 is needed in order to map from pinLocations to list index
pinLoc = self.p.pinLocation[pinNum] - 1
else:
pinLoc = pinNum
pinLinPow = powers[pinLoc]
self.p[powerKey][pinNum] = pinLinPow
# If using the *powerKeySuffix* parameter, we also need to set total power, which
# is sum of neutron and gamma powers. We assume that a solo gamma calculation
# to set total power does not make sense.
if powerKeySuffix:
if powerKeySuffix == GAMMA:
if self.p[f"linPowByPin{NEUTRON}"] is None:
msg = (
"Neutron power has not been set yet. Cannot set total power for "
f"{self}."
)
raise UnboundLocalError(msg)
self.p.linPowByPin = self.p[f"linPowByPin{NEUTRON}"] + self.p[powerKey]
else:
self.p.linPowByPin = self.p[powerKey]
[docs] def rotate(self, rad):
"""
Rotates a block's spatially varying parameters by a specified angle in the
counter-clockwise direction.
The parameters must have a ParamLocation of either CORNERS or EDGES and must be a
Python list of length 6 in order to be eligible for rotation; all parameters that
do not meet these two criteria are not rotated.
The pin indexing, as stored on the pinLocation parameter, is also updated via
:py:meth:`rotatePins <armi.reactor.blocks.HexBlock.rotatePins>`.
Parameters
----------
rad: float, required
Angle of counter-clockwise rotation in units of radians. Rotations must be
in 60-degree increments (i.e., PI/6, PI/3, PI, 2 * PI/3, 5 * PI/6,
and 2 * PI)
See Also
--------
:py:meth:`rotatePins <armi.reactor.blocks.HexBlock.rotatePins>`
"""
rotNum = round((rad % (2 * math.pi)) / math.radians(60))
self.rotatePins(rotNum)
params = self.p.paramDefs.atLocation(ParamLocation.CORNERS).names
params += self.p.paramDefs.atLocation(ParamLocation.EDGES).names
for param in params:
if isinstance(self.p[param], list):
if len(self.p[param]) == 6:
self.p[param] = self.p[param][-rotNum:] + self.p[param][:-rotNum]
elif self.p[param] == []:
# List hasn't been defined yet, no warning needed.
pass
else:
msg = (
"No rotation method defined for spatial parameters that aren't "
"defined once per hex edge/corner. No rotation performed "
f"on {param}"
)
runLog.warning(msg)
elif isinstance(self.p[param], numpy.ndarray):
if len(self.p[param]) == 6:
self.p[param] = numpy.concatenate(
(self.p[param][-rotNum:], self.p[param][:-rotNum])
)
elif len(self.p[param]) == 0:
# Hasn't been defined yet, no warning needed.
pass
else:
msg = (
"No rotation method defined for spatial parameters that aren't "
"defined once per hex edge/corner. No rotation performed "
f"on {param}"
)
runLog.warning(msg)
elif isinstance(self.p[param], (int, float)):
# this is a scalar and there shouldn't be any rotation.
pass
elif self.p[param] is None:
# param is not set yet. no rotations as well.
pass
else:
raise TypeError(
f"b.rotate() method received unexpected data type for {param} on block {self}\n"
+ f"expected list, np.ndarray, int, or float. received {self.p[param]}"
)
# This specifically uses the .get() functionality to avoid an error if this
# parameter does not exist.
dispx = self.p.get("displacementX")
dispy = self.p.get("displacementY")
if (dispx is not None) and (dispy is not None):
self.p.displacementX = dispx * math.cos(rad) - dispy * math.sin(rad)
self.p.displacementY = dispx * math.sin(rad) + dispy * math.cos(rad)
[docs] def rotatePins(self, rotNum, justCompute=False):
"""
Rotate the pins of a block, which means rotating the indexing of pins. Note that this does
not rotate all block quantities, just the pins.
Parameters
----------
rotNum : int, required
An integer from 0 to 5, indicating the number of counterclockwise 60-degree rotations
from the CURRENT orientation. Degrees of counter-clockwise rotation = 60*rot
justCompute : boolean, optional
If True, rotateIndexLookup will be returned but NOT assigned to the object parameter
self.p.pinLocation. If False, rotateIndexLookup will be returned AND assigned to the
object variable self.p.pinLocation. Useful for figuring out which rotation is best
to minimize burnup, etc.
Returns
-------
rotateIndexLookup : dict of ints
This is an index lookup (or mapping) between pin ids and pin locations. The pin
indexing is 1-D (not ring,pos or GEODST). The "ARMI pin ordering" is used for location,
which is counter-clockwise from 1 o'clock. Pin ids are always consecutively
ordered starting at 1, while pin locations are not once a rotation has been
applied.
Notes
-----
Changing (x,y) positions of pins does NOT constitute rotation, because the indexing of pin
atom densities must be re-ordered. Re-order indexing of pin-level quantities, NOT (x,y)
locations of pins. Otherwise, subchannel input will be in wrong order.
How rotations works is like this. There are pins with unique pin numbers in each block.
These pin numbers will not change no matter what happens to a block, so if you have pin 1,
you always have pin 1. However, these pins are all in pinLocations, and these are what
change with rotations. At BOL, a pin's pinLocation is equal to its pin number, but after
a rotation, this will no longer be so.
So, all params that don't care about exactly where in space the pin is (such as depletion)
can just use the pin number, but anything that needs to know the spatial location (such as
fluxRecon, which interpolates the flux spatially, or subchannel codes, which needs to know where the
power is) need to map through the pinLocation parameters.
This method rotates the pins by changing the pinLocation parameter.
See Also
--------
armi.reactor.blocks.HexBlock.rotate
Rotates the entire block (pins, ducts, and spatial quantities).
Examples
--------
rotateIndexLookup[i_after_rotation-1] = i_before_rotation-1
"""
if not 0 <= rotNum <= 5:
raise ValueError(
"Cannot rotate {0} to rotNum {1}. Must be 0-5. ".format(self, rotNum)
)
# Pin numbers start at 1. Number of pins in the block is assumed to be based on
# cladding count.
numPins = self.getNumComponents(Flags.CLAD)
rotateIndexLookup = dict(zip(range(1, numPins + 1), range(1, numPins + 1)))
# Look up the current orientation and add this to it. The math below just rotates
# from the reference point so we need a total rotation.
rotNum = int((self.getRotationNum() + rotNum) % 6)
# non-trivial rotation requested
# start at 2 because pin 1 never changes (it's in the center!)
for pinNum in range(2, numPins + 1):
if rotNum == 0:
# Rotation to reference orientation. Pin locations are pin IDs.
pass
else:
# Determine the pin ring. Rotation does not change the pin ring!
ring = int(
math.ceil((3.0 + math.sqrt(9.0 - 12.0 * (1.0 - pinNum))) / 6.0)
)
# Rotate the pin position (within the ring, which does not change)
tot_pins = 1 + 3 * ring * (ring - 1)
newPinLocation = pinNum + (ring - 1) * rotNum
if newPinLocation > tot_pins:
newPinLocation -= (ring - 1) * 6
# Assign "before" and "after" pin indices to the index lookup
rotateIndexLookup[pinNum] = newPinLocation
# Because the above math creates indices based on the absolute rotation number,
# the old values of pinLocation (if they've been set in the past) can be overwritten
# with new numbers
if not justCompute:
self.setRotationNum(rotNum)
self.p["pinLocation"] = [
rotateIndexLookup[pinNum] for pinNum in range(1, numPins + 1)
]
return rotateIndexLookup
[docs] def verifyBlockDims(self):
"""Perform some checks on this type of block before it is assembled."""
try:
wireComp = self.getComponent(Flags.WIRE)
ductComps = self.getComponents(Flags.DUCT)
cladComp = self.getComponent(Flags.CLAD)
except ValueError:
# there are probably more that one clad/wire, so we really dont know what this block looks like
runLog.info(
"Block design {} is too complicated to verify dimensions. Make sure they "
"are correct!".format(self)
)
return
# check wire wrap in contact with clad
if (
self.getComponent(Flags.CLAD) is not None
and self.getComponent(Flags.WIRE) is not None
):
wwCladGap = self.getWireWrapCladGap(cold=True)
if round(wwCladGap, 6) != 0.0:
runLog.warning(
"The gap between wire wrap and clad in block {} was {} cm. Expected 0.0."
"".format(self, wwCladGap),
single=True,
)
# check clad duct overlap
pinToDuctGap = self.getPinToDuctGap(cold=True)
# Allow for some tolerance; user input precision may lead to slight negative
# gaps
if pinToDuctGap is not None and pinToDuctGap < -0.005:
raise ValueError(
"Gap between pins and duct is {0:.4f} cm in {1}. Make more room.".format(
pinToDuctGap, self
)
)
elif pinToDuctGap is None:
# only produce a warning if pin or clad are found, but not all of pin, clad and duct. We
# may need to tune this logic a bit
ductComp = next(iter(ductComps), None)
if (cladComp is not None or wireComp is not None) and any(
[c is None for c in (wireComp, cladComp, ductComp)]
):
runLog.warning(
"Some component was missing in {} so pin-to-duct gap not calculated"
"".format(self)
)
[docs] def getPinToDuctGap(self, cold=False):
"""
Returns the distance in cm between the outer most pin and the duct in a block.
Parameters
----------
cold : boolean
Determines whether the results should be cold or hot dimensions.
Returns
-------
pinToDuctGap : float
Returns the diameteral gap between the outer most pins in a hex pack to the duct inner
face to face in cm.
"""
wire = self.getComponent(Flags.WIRE)
ducts = sorted(self.getChildrenWithFlags(Flags.DUCT))
duct = None
if any(ducts):
duct = ducts[0]
if not isinstance(duct, components.Hexagon):
# getPinCenterFlatToFlat only works for hexes
# inner most duct might be circle or some other shape
duct = None
elif isinstance(duct, components.HoledHexagon):
# has no ip and is circular on inside so following
# code will not work
duct = None
clad = self.getComponent(Flags.CLAD)
if any(c is None for c in (duct, wire, clad)):
return None
# note, if nRings was a None, this could be for a non-hex packed fuel assembly
# see thermal hydraulic design basis for description of equation
pinCenterFlatToFlat = self.getPinCenterFlatToFlat(cold=cold)
pinOuterFlatToFlat = (
pinCenterFlatToFlat
+ clad.getDimension("od", cold=cold)
+ 2.0 * wire.getDimension("od", cold=cold)
)
ductMarginToContact = duct.getDimension("ip", cold=cold) - pinOuterFlatToFlat
pinToDuctGap = ductMarginToContact / 2.0
return pinToDuctGap
[docs] def getRotationNum(self):
"""Get index 0 through 5 indicating number of rotations counterclockwise around the z-axis."""
return (
numpy.rint(self.p.orientation[2] / 360.0 * 6) % 6
) # assume rotation only in Z
[docs] def setRotationNum(self, rotNum):
"""
Set orientation based on a number 0 through 5 indicating number of rotations
counterclockwise around the z-axis.
"""
self.p.orientation[2] = 60.0 * rotNum
[docs] def getSymmetryFactor(self):
"""
Return a factor between 1 and N where 1/N is how much cut-off by symmetry lines this mesh
cell is.
Reactor-level meshes have symmetry information so we have a reactor for this to work. That's
why it's not implemented on the grid/locator level.
When edge-assemblies are included on both edges (i.e. MCNP or DIF3D-FD 1/3-symmetric cases),
the edge assemblies have symmetry factors of 2.0. Otherwise (DIF3D-nodal) there's a full
assembly on the bottom edge (overhanging) and no assembly at the top edge so the ones at the
bottom are considered full (symmetryFactor=1).
If this block is not in any grid at all, then there can be no symmetry so return 1.
"""
try:
symmetry = self.parent.spatialLocator.grid.symmetry
except: # noqa: bare-except
return 1.0
if (
symmetry.domain == geometry.DomainType.THIRD_CORE
and symmetry.boundary == geometry.BoundaryType.PERIODIC
):
indices = self.spatialLocator.getCompleteIndices()
if indices[0] == 0 and indices[1] == 0:
# central location
return 3.0
else:
symmetryLine = self.core.spatialGrid.overlapsWhichSymmetryLine(indices)
# detect if upper edge assemblies are included. Doing this is the only way to know
# definitively whether or not the edge assemblies are half-assems or full.
# seeing the first one is the easiest way to detect them.
# Check it last in the and statement so we don't waste time doing it.
upperEdgeLoc = self.core.spatialGrid[-1, 2, 0]
if symmetryLine in [
grids.BOUNDARY_0_DEGREES,
grids.BOUNDARY_120_DEGREES,
] and bool(self.core.childrenByLocator.get(upperEdgeLoc)):
return 2.0
return 1.0
[docs] def autoCreateSpatialGrids(self):
"""
Given a block without a spatialGrid, create a spatialGrid and give its children
the corresponding spatialLocators (if it is a simple block).
In this case, a simple block would be one that has either multiplicity of
components equal to 1 or N but no other multiplicities. Also, this should only
happen when N fits exactly into a given number of hex rings. Otherwise, do not
create a grid for this block.
Notes
-----
If the block meets all the conditions, we gather all components to either be a multiIndexLocation containing all
of the pin positions, otherwise, locator is the center (0,0).
Also, this only works on blocks that have 'flat side up'.
Raises
------
ValueError
If the multiplicities of the block are not only 1 or N or if generated ringNumber leads to more positions than necessary.
"""
# Check multiplicities...
mults = {c.getDimension("mult") for c in self.iterComponents()}
if len(mults) != 2 or 1 not in mults:
raise ValueError(
"Could not create a spatialGrid for block {}, multiplicities are not 1 or N they are {}".format(
self.p.type, mults
)
)
ringNumber = hexagon.numRingsToHoldNumCells(self.getNumPins())
# For the below to work, there must not be multiple wire or multiple clad types.
# note that it's the pointed end of the cell hexes that are up (but the
# macro shape of the pins forms a hex with a flat top fitting in the assembly)
grid = grids.HexGrid.fromPitch(
self.getPinPitch(cold=True), numRings=0, pointedEndUp=True
)
spatialLocators = grids.MultiIndexLocation(grid=self.spatialGrid)
numLocations = 0
for ring in range(ringNumber):
numLocations = numLocations + hexagon.numPositionsInRing(ring + 1)
if numLocations != self.getNumPins():
raise ValueError(
"Cannot create spatialGrid, number of locations in rings{} not equal to pin number{}".format(
numLocations, self.getNumPins()
)
)
i = 0
for ring in range(ringNumber):
for pos in range(grid.getPositionsInRing(ring + 1)):
i, j = grid.getIndicesFromRingAndPos(ring + 1, pos + 1)
spatialLocators.append(grid[i, j, 0])
if self.spatialGrid is None:
self.spatialGrid = grid
for c in self:
if c.getDimension("mult") > 1:
c.spatialLocator = spatialLocators
elif c.getDimension("mult") == 1:
c.spatialLocator = grids.CoordinateLocation(0.0, 0.0, 0.0, grid)
[docs] def getPinCenterFlatToFlat(self, cold=False):
"""Return the flat-to-flat distance between the centers of opposing pins in the outermost ring."""
clad = self.getComponent(Flags.CLAD)
nRings = hexagon.numRingsToHoldNumCells(clad.getDimension("mult"))
pinPitch = self.getPinPitch(cold=cold)
pinCenterCornerToCorner = 2 * (nRings - 1) * pinPitch
pinCenterFlatToFlat = math.sqrt(3.0) / 2.0 * pinCenterCornerToCorner
return pinCenterFlatToFlat
[docs] def hasPinPitch(self):
"""Return True if the block has enough information to calculate pin pitch."""
return (self.getComponent(Flags.CLAD) is not None) and (
self.getComponent(Flags.WIRE) is not None
)
[docs] def getPinPitch(self, cold=False):
"""
Get the pin pitch in cm.
Assumes that the pin pitch is defined entirely by contacting cladding tubes
and wire wraps. Grid spacers not yet supported.
Parameters
----------
cold : boolean
Determines whether the dimensions should be cold or hot
Returns
-------
pinPitch : float
pin pitch in cm
"""
try:
clad = self.getComponent(Flags.CLAD)
wire = self.getComponent(Flags.WIRE)
except ValueError:
raise ValueError(
"Block {} has multiple clad and wire components,"
" so pin pitch is not well-defined.".format(self)
)
if wire and clad:
return clad.getDimension("od", cold=cold) + wire.getDimension(
"od", cold=cold
)
else:
raise ValueError(
"Cannot get pin pitch in {} because it does not have a wire and a clad".format(
self
)
)
[docs] def getWettedPerimeter(self):
"""Return the total wetted perimeter of the block in cm."""
# flags pertaining to hexagon components where the interior of the hexagon is wetted
wettedHollowHexagonComponentFlags = (
Flags.DUCT,
Flags.GRID_PLATE,
Flags.INLET_NOZZLE,
Flags.HANDLING_SOCKET,
)
# flags pertaining to circular pin components where the exterior of the circle is wetted
wettedPinComponentFlags = (
Flags.CLAD,
Flags.WIRE,
)
# flags pertaining to circular components where both the interior and exterior of the circle are wetted
wettedHollowCircleComponentFlags = (Flags.DUCT | Flags.INNER,)
# obtain all wetted components based on type
wettedHollowHexagonComponents = []
for flag in wettedHollowHexagonComponentFlags:
c = self.getComponent(flag, exact=True)
wettedHollowHexagonComponents.append(c) if c else None
wettedPinComponents = []
for flag in wettedPinComponentFlags:
c = self.getComponent(flag, exact=True)
wettedPinComponents.append(c) if c else None
wettedHollowCircleComponents = []
for flag in wettedHollowCircleComponentFlags:
c = self.getComponent(flag, exact=True)
wettedHollowCircleComponents.append(c) if c else None
# calculate wetted perimeters according to their geometries
# hollow hexagon = 6 * ip / sqrt(3)
wettedHollowHexagonPerimeter = 0.0
for c in wettedHollowHexagonComponents:
wettedHollowHexagonPerimeter += (
6 * c.getDimension("ip") / math.sqrt(3) if c else 0.0
)
# solid circle = NumPins * pi * (Comp Diam + Wire Diam)
wettedPinPerimeter = 0.0
for c in wettedPinComponents:
correctionFactor = 1.0
if isinstance(c, Helix):
# account for the helical wire wrap
correctionFactor = numpy.hypot(
1.0,
math.pi
* c.getDimension("helixDiameter")
/ c.getDimension("axialPitch"),
)
wettedPinPerimeter += c.getDimension("od") * correctionFactor
wettedPinPerimeter *= self.getNumPins() * math.pi
# hollow circle = (id + od) * pi
wettedHollowCirclePerimeter = 0.0
for c in wettedHollowCircleComponents:
wettedHollowCirclePerimeter += (
c.getDimension("id") + c.getDimension("od") if c else 0.0
)
wettedHollowCirclePerimeter *= math.pi
return (
wettedHollowHexagonPerimeter
+ wettedPinPerimeter
+ wettedHollowCirclePerimeter
)
[docs] def getFlowArea(self):
"""Return the total flowing coolant area of the block in cm^2."""
return self.getComponent(Flags.COOLANT, exact=True).getArea()
[docs] def getHydraulicDiameter(self):
"""
Return the hydraulic diameter in this block in cm.
Hydraulic diameter is 4A/P where A is the flow area and P is the wetted perimeter.
In a hex assembly, the wetted perimeter includes the cladding, the wire wrap, and the
inside of the duct. The flow area is the inner area of the duct minus the area of the
pins and the wire.
To convert the inner hex pitch into a perimeter, first convert to side, then
multiply by 6.
p = sqrt(3)*s
l = 6*p/sqrt(3)
"""
return 4.0 * self.getFlowArea() / self.getWettedPerimeter()
[docs]class CartesianBlock(Block):
PITCH_DIMENSION = "widthOuter"
PITCH_COMPONENT_TYPE = components.Rectangle
[docs] def getMaxArea(self):
"""Get area of this block if it were totally full."""
xw, yw = self.getPitch()
return xw * yw
[docs] def setPitch(self, val, updateBolParams=False):
raise NotImplementedError(
"Directly setting the pitch of a cartesian block is currently not supported."
)
[docs] def getSymmetryFactor(self):
"""
Return a factor between 1 and N where 1/N is how much cut-off by symmetry lines this mesh
cell is.
"""
if self.core is not None:
indices = self.spatialLocator.getCompleteIndices()
if self.core.symmetry.isThroughCenterAssembly:
if indices[0] == 0 and indices[1] == 0:
# central location
return 4.0
elif indices[0] == 0 or indices[1] == 0:
# edge location
return 2.0
return 1.0
[docs] def getPinCenterFlatToFlat(self, cold=False):
"""Return the flat-to-flat distance between the centers of opposing pins in the outermost ring."""
clad = self.getComponent(Flags.CLAD)
nRings = hexagon.numRingsToHoldNumCells(clad.getDimension("mult"))
pinPitch = self.getPinPitch(cold=cold)
return 2 * (nRings - 1) * pinPitch
[docs]class ThRZBlock(Block):
# be sure to fill ThRZ blocks with only 3D components - components with explicit getVolume methods
[docs] def getMaxArea(self):
"""Return the area of the Theta-R-Z block if it was totally full."""
raise NotImplementedError(
"Cannot get max area of a TRZ block. Fully specify your geometry."
)
[docs] def radialInner(self):
"""Return a smallest radius of all the components."""
innerRadii = self.getDimensions("inner_radius")
smallestInner = min(innerRadii) if innerRadii else None
return smallestInner
[docs] def radialOuter(self):
"""Return a largest radius of all the components."""
outerRadii = self.getDimensions("outer_radius")
largestOuter = max(outerRadii) if outerRadii else None
return largestOuter
[docs] def thetaInner(self):
"""Return a smallest theta of all the components."""
innerTheta = self.getDimensions("inner_theta")
smallestInner = min(innerTheta) if innerTheta else None
return smallestInner
[docs] def thetaOuter(self):
"""Return a largest theta of all the components."""
outerTheta = self.getDimensions("outer_theta")
largestOuter = max(outerTheta) if outerTheta else None
return largestOuter
[docs] def axialInner(self):
"""Return the lower z-coordinate."""
return self.getDimensions("inner_axial")
[docs] def axialOuter(self):
"""Return the upper z-coordinate."""
return self.getDimensions("outer_axial")
[docs] def verifyBlockDims(self):
"""Perform dimension checks related to ThetaRZ blocks."""
return