From bb1d08dc4a5e493b76757b330a23d43cefa308c3 Mon Sep 17 00:00:00 2001 From: Jake Hader Date: Fri, 30 Apr 2021 21:54:51 -0700 Subject: [PATCH] Add testing and validation checks to the cross section settings. (#289) Adding validation checks in `crossSectionSettings` to warn the user if extra options are provided in the various geometry options amd enforcing that the `fileLocation` and `geometry` cannot both be set simultaneously. The settings validation and the unit testing around the cross section settings were improved. --- armi/bookkeeping/db/database3.py | 4 +- armi/bookkeeping/db/tests/test_database3.py | 2 +- armi/bookkeeping/historyTracker.py | 2 +- armi/bookkeeping/mainInterface.py | 2 +- armi/materials/lead.py | 2 +- armi/materials/leadBismuth.py | 2 +- armi/materials/material.py | 4 +- armi/materials/mgO.py | 2 +- armi/materials/siC.py | 2 +- armi/materials/sulfur.py | 2 +- armi/materials/uThZr.py | 2 +- armi/materials/uZr.py | 2 +- armi/nucDirectory/nucDir.py | 6 +- armi/nuclearDataIO/xsNuclides.py | 4 +- armi/physics/neutronics/__init__.py | 4 - .../neutronics/crossSectionGroupManager.py | 12 +- .../neutronics/crossSectionSettings.py | 696 +++++++++++++----- .../lumpedFissionProduct.py | 2 +- .../tests/test_lumpedFissionProduct.py | 28 +- .../latticePhysics/latticePhysicsWriter.py | 7 +- .../tests/test_crossSectionSettings.py | 213 +++++- .../tests/test_cross_section_manager.py | 12 + .../neutronics/tests/test_neutronicsPlugin.py | 1 - armi/reactor/assemblyLists.py | 4 +- armi/reactor/components/__init__.py | 6 +- armi/reactor/composites.py | 6 +- armi/reactor/grids.py | 2 +- armi/reactor/reactors.py | 6 +- armi/runLog.py | 2 +- armi/tests/armiRun.yaml | 5 - 30 files changed, 772 insertions(+), 272 deletions(-) diff --git a/armi/bookkeeping/db/database3.py b/armi/bookkeeping/db/database3.py index a710c77b5..ebb178069 100644 --- a/armi/bookkeeping/db/database3.py +++ b/armi/bookkeeping/db/database3.py @@ -237,7 +237,7 @@ def interactEveryNode(self, cycle, node): self._db.syncToSharedFolder() def interactEOC(self, cycle=None): - """In case anything changed since last cycle (e.g. rxSwing), update DB. """ + """In case anything changed since last cycle (e.g. rxSwing), update DB.""" # We cannot presume whether we are at EOL based on cycle and cs["nCycles"], # since cs["nCycles"] is not a difinitive indicator of EOL; ultimately the # Operator has the final say. @@ -248,7 +248,7 @@ def interactEOC(self, cycle=None): self._db.writeToDB(self.r) def interactEOL(self): - """DB's should be closed at run's end. """ + """DB's should be closed at run's end.""" # minutesSinceStarts should include as much of the ARMI run as possible so EOL # is necessary, too. self.r.core.p.minutesSinceStart = (time.time() - self.r.core.timeOfStart) / 60.0 diff --git a/armi/bookkeeping/db/tests/test_database3.py b/armi/bookkeeping/db/tests/test_database3.py index 6f1c6e20a..352353401 100644 --- a/armi/bookkeeping/db/tests/test_database3.py +++ b/armi/bookkeeping/db/tests/test_database3.py @@ -28,7 +28,7 @@ class TestDatabase3(unittest.TestCase): - r""" Tests for the Database3 class """ + r"""Tests for the Database3 class""" def setUp(self): self.td = TemporaryDirectoryChanger() diff --git a/armi/bookkeeping/historyTracker.py b/armi/bookkeeping/historyTracker.py index 4e379acb8..19210f719 100644 --- a/armi/bookkeeping/historyTracker.py +++ b/armi/bookkeeping/historyTracker.py @@ -349,7 +349,7 @@ def getTimeIndices(self, a=None, boc=False, moc=False, eoc=False): return self.filterTimeIndices(timeIndices, boc, moc, eoc) def getBOCEOCTimeIndices(self, assem=None): - r"""returns a list of time step indices that only include BOC and EOC, no intermediate ones. """ + r"""returns a list of time step indices that only include BOC and EOC, no intermediate ones.""" tIndices = self.getTimeIndices(assem) # list of times in years counter = 0 filtered = [] diff --git a/armi/bookkeeping/mainInterface.py b/armi/bookkeeping/mainInterface.py index 73b489616..9c2819bf8 100644 --- a/armi/bookkeeping/mainInterface.py +++ b/armi/bookkeeping/mainInterface.py @@ -134,7 +134,7 @@ def _moveFiles(self): raise InputError("Failed to process copyFilesTo/copyFilesFrom") def interactBOC(self, cycle=None): - r"""typically the first interface to interact beginning of cycle. """ + r"""typically the first interface to interact beginning of cycle.""" runLog.important("Beginning of Cycle {0}".format(cycle)) runLog.LOG.clearSingleWarnings() diff --git a/armi/materials/lead.py b/armi/materials/lead.py index 66c6dc3a1..88a8fab9f 100644 --- a/armi/materials/lead.py +++ b/armi/materials/lead.py @@ -34,7 +34,7 @@ def volumetricExpansion(self, Tk=None, Tc=None): return 1.0 / (9516.9 - Tk) def setDefaultMassFracs(self): - r""" mass fractions""" + r"""mass fractions""" self.setMassFrac("PB", 1) def density(self, Tk=None, Tc=None): diff --git a/armi/materials/leadBismuth.py b/armi/materials/leadBismuth.py index 4b17db113..b82d4df24 100644 --- a/armi/materials/leadBismuth.py +++ b/armi/materials/leadBismuth.py @@ -29,7 +29,7 @@ class LeadBismuth(material.Fluid): name = "Lead Bismuth" def setDefaultMassFracs(self): - r""" mass fractions""" + r"""mass fractions""" self.setMassFrac("PB", 0.445) self.setMassFrac("BI209", 0.555) diff --git a/armi/materials/material.py b/armi/materials/material.py index 772bfbafa..e19194205 100644 --- a/armi/materials/material.py +++ b/armi/materials/material.py @@ -451,7 +451,7 @@ def thermalConductivity(self, Tk=None, Tc=None): return self.p.thermalConductivity def getProperty(self, propName, Tk=None, Tc=None, **kwargs): - r"""gets properties in a way that caches them. """ + r"""gets properties in a way that caches them.""" Tk = getTk(Tc, Tk) cached = self._getCached(propName) @@ -512,7 +512,7 @@ def getMassFrac( return self.p.massFrac.get(nucName, 0.0) def clearMassFrac(self): - r"""zero out all nuclide mass fractions. """ + r"""zero out all nuclide mass fractions.""" self.p.massFrac.clear() self.p.massFracNorm = 0.0 diff --git a/armi/materials/mgO.py b/armi/materials/mgO.py index 2c857a6f1..5284ee0f5 100644 --- a/armi/materials/mgO.py +++ b/armi/materials/mgO.py @@ -24,7 +24,7 @@ class MgO(Material): name = "MgO" def setDefaultMassFracs(self): - r""" mass fractions""" + r"""mass fractions""" self.setMassFrac("MG", 0.603035897) self.setMassFrac("O16", 0.396964103) diff --git a/armi/materials/siC.py b/armi/materials/siC.py index c4cda8737..6d5603c5c 100644 --- a/armi/materials/siC.py +++ b/armi/materials/siC.py @@ -25,7 +25,7 @@ class SiC(Material): - r"""""" + r""" """ name = "Silicon Carbide" thermalScatteringLaws = ( tsl.byNbAndCompound[nb.byName["C"], tsl.SIC], diff --git a/armi/materials/sulfur.py b/armi/materials/sulfur.py index 9e76e4c8f..93317f793 100644 --- a/armi/materials/sulfur.py +++ b/armi/materials/sulfur.py @@ -37,7 +37,7 @@ def setDefaultMassFracs(self): self.setMassFrac("S36", 0.002) def density(self, Tk=None, Tc=None): - r""" P. Espeau, R. Ceolin "density of molten sulfur in the 334-508K range" """ + r"""P. Espeau, R. Ceolin "density of molten sulfur in the 334-508K range" """ Tk = getTk(Tc, Tk) self.checkTempRange(334, 430, Tk, "density") diff --git a/armi/materials/uThZr.py b/armi/materials/uThZr.py index e6747706e..b809d1ee6 100644 --- a/armi/materials/uThZr.py +++ b/armi/materials/uThZr.py @@ -41,7 +41,7 @@ def applyInputParams(self, U235_wt_frac=None, ZR_wt_frac=None, TH_wt_frac=None): self.p.zrFrac = ZR_wt_frac def setDefaultMassFracs(self): - r""" U-ZR mass fractions""" + r"""U-ZR mass fractions""" self.setMassFrac("U238", 0.8) self.setMassFrac("U235", 0.1) self.setMassFrac("ZR", 0.09999) diff --git a/armi/materials/uZr.py b/armi/materials/uZr.py index 4a8d67c20..54355b6fb 100644 --- a/armi/materials/uZr.py +++ b/armi/materials/uZr.py @@ -47,7 +47,7 @@ class UZr(material.FuelMaterial): uFracDefault = 1.0 - zrFracDefault def setDefaultMassFracs(self): - r""" U-Pu-Zr mass fractions""" + r"""U-Pu-Zr mass fractions""" u235Enrichment = 0.1 self.p.uFrac = self.uFracDefault self.p.zrFrac = self.zrFracDefault diff --git a/armi/nucDirectory/nucDir.py b/armi/nucDirectory/nucDir.py index 192cf3791..5ced9ca19 100644 --- a/armi/nucDirectory/nucDir.py +++ b/armi/nucDirectory/nucDir.py @@ -217,12 +217,12 @@ def getRebusLabel(name): def getMc2LabelFromRebusLabel(rebusLabel): - r"""""" + r""" """ return getMc2Label(rebusLabel) def getRebusLabelFromMc2Label(mc2Label): - r"""""" + r""" """ return getRebusNameFromMC2(mc2Label) @@ -470,7 +470,7 @@ def getNameFromMC2(mc2LibLabel=None, mc2Label=None): def getStructuralElements(): - r""" return list of element symbol in structure """ + r"""return list of element symbol in structure""" return ["MN", "W", "HE", "C", "CR", "FE", "MO", "NI", "SI", "V"] diff --git a/armi/nuclearDataIO/xsNuclides.py b/armi/nuclearDataIO/xsNuclides.py index 00c986f3c..4a77c0902 100644 --- a/armi/nuclearDataIO/xsNuclides.py +++ b/armi/nuclearDataIO/xsNuclides.py @@ -228,7 +228,7 @@ def _mergeAttributes(this, other, attrName): def plotScatterMatrix(scatterMatrix, scatterTypeLabel="", fName=None): - r"""plots a matrix to show scattering. """ + r"""plots a matrix to show scattering.""" from matplotlib import pyplot pyplot.imshow(scatterMatrix.todense(), interpolation="nearest") @@ -244,7 +244,7 @@ def plotScatterMatrix(scatterMatrix, scatterTypeLabel="", fName=None): def compareScatterMatrix(scatterMatrix1, scatterMatrix2, fName=None): - """Compares scatter matrices graphically between libraries. """ + """Compares scatter matrices graphically between libraries.""" from matplotlib import pyplot diff = scatterMatrix1 - scatterMatrix2 diff --git a/armi/physics/neutronics/__init__.py b/armi/physics/neutronics/__init__.py index 5ee139722..d4d7e7f3a 100644 --- a/armi/physics/neutronics/__init__.py +++ b/armi/physics/neutronics/__init__.py @@ -91,10 +91,6 @@ def defineSettings(): settings = [ crossSectionSettings.XSSettingDef( CONF_CROSS_SECTION, - default=crossSectionSettings.XSSettings(), - label="Cross section control", - description="Data structure defining how cross sections are created", - schema=crossSectionSettings.XS_SCHEMA, ) ] settings += neutronicsSettings.defineSettings() diff --git a/armi/physics/neutronics/crossSectionGroupManager.py b/armi/physics/neutronics/crossSectionGroupManager.py index b8040a2e5..2201c44b2 100644 --- a/armi/physics/neutronics/crossSectionGroupManager.py +++ b/armi/physics/neutronics/crossSectionGroupManager.py @@ -615,7 +615,11 @@ def __init__(self, r, cs): def interactBOL(self): # now that all cs settings are loaded, apply defaults to compound XS settings - self.cs[CONF_CROSS_SECTION].setDefaults(self.cs) + + self.cs[CONF_CROSS_SECTION].setDefaults( + self.cs["xsBlockRepresentation"], + self.cs["disableBlockTypeExclusionInXsGeneration"], + ) def interactBOC(self, cycle=None): """ @@ -1131,12 +1135,6 @@ def updateNuclideTemperatures(self, blockCollectionByXsGroup=None): "FluxWeightedAverage": FluxWeightedAverageBlockCollection, } -HOMOGENEOUS_BLOCK_COLLECTIONS = { - "Median": MedianBlockCollection, - "Average": AverageBlockCollection, - "FluxWeightedAverage": FluxWeightedAverageBlockCollection, -} - def blockCollectionFactory(xsSettings, allNuclidesInProblem): """ diff --git a/armi/physics/neutronics/crossSectionSettings.py b/armi/physics/neutronics/crossSectionSettings.py index 89f1b3db9..c114396a4 100644 --- a/armi/physics/neutronics/crossSectionSettings.py +++ b/armi/physics/neutronics/crossSectionSettings.py @@ -25,18 +25,17 @@ See detailed docs in `:doc: Lattice Physics `. """ + +from typing import Dict, Union + import voluptuous as vol +from armi import runLog from armi.settings import Setting -from armi.physics.neutronics.crossSectionGroupManager import ( - BLOCK_COLLECTIONS, - HOMOGENEOUS_BLOCK_COLLECTIONS, -) - -# define conf and schema here since this is closest to where the objects live -XS_GEOM_TYPES = {"0D", "2D hex", "1D slab", "1D cylinder"} +from armi.physics.neutronics.crossSectionGroupManager import BLOCK_COLLECTIONS +CONF_XSID = "xsID" CONF_GEOM = "geometry" CONF_BLOCK_REPRESENTATION = "blockRepresentation" CONF_DRIVER = "driverID" @@ -51,19 +50,72 @@ CONF_FILE_LOCATION = "fileLocation" CONF_MESH_PER_CM = "meshSubdivisionsPerCm" -SINGLE_XS_SCHEMA = vol.Schema( +# These may be used as arguments to ``latticePhysicsInterface._getGeomDependentWriters``. +# This could be an ENUM later. +XS_GEOM_TYPES = { + "0D", + "1D slab", + "1D cylinder", + "2D hex", +} + +# This dictionary defines the valid set of inputs based on +# the geometry type within the ``XSModelingOptions`` +_VALID_INPUTS_BY_GEOMETRY_TYPE = { + "0D": { + CONF_XSID, + CONF_GEOM, + CONF_BUCKLING, + CONF_DRIVER, + CONF_BLOCKTYPES, + CONF_BLOCK_REPRESENTATION, + }, + "1D slab": { + CONF_XSID, + CONF_GEOM, + CONF_MESH_PER_CM, + CONF_BLOCKTYPES, + CONF_BLOCK_REPRESENTATION, + }, + "1D cylinder": { + CONF_XSID, + CONF_GEOM, + CONF_MERGE_INTO_CLAD, + CONF_DRIVER, + CONF_HOMOGBLOCK, + CONF_INTERNAL_RINGS, + CONF_EXTERNAL_RINGS, + CONF_MESH_PER_CM, + CONF_BLOCKTYPES, + CONF_BLOCK_REPRESENTATION, + }, + "2D hex": { + CONF_XSID, + CONF_GEOM, + CONF_BUCKLING, + CONF_EXTERNAL_DRIVER, + CONF_DRIVER, + CONF_REACTION_DRIVER, + CONF_EXTERNAL_RINGS, + CONF_BLOCK_REPRESENTATION, + }, +} + +_SINGLE_XS_SCHEMA = vol.Schema( { - vol.Optional(CONF_GEOM, default="0D"): vol.All(str, vol.In(XS_GEOM_TYPES)), + vol.Optional(CONF_GEOM): vol.All(str, vol.In(XS_GEOM_TYPES)), vol.Optional(CONF_BLOCK_REPRESENTATION): vol.All( str, - vol.In(set(BLOCK_COLLECTIONS.keys()) | set(HOMOGENEOUS_BLOCK_COLLECTIONS)), + vol.In( + set(BLOCK_COLLECTIONS.keys()), + ), ), vol.Optional(CONF_DRIVER): str, vol.Optional(CONF_BUCKLING): bool, vol.Optional(CONF_REACTION_DRIVER): str, vol.Optional(CONF_BLOCKTYPES): [str], vol.Optional(CONF_HOMOGBLOCK): bool, - vol.Optional(CONF_EXTERNAL_DRIVER, default=True): bool, + vol.Optional(CONF_EXTERNAL_DRIVER): bool, vol.Optional(CONF_INTERNAL_RINGS): vol.Coerce(int), vol.Optional(CONF_EXTERNAL_RINGS): vol.Coerce(int), vol.Optional(CONF_MERGE_INTO_CLAD): [str], @@ -72,44 +124,264 @@ } ) -XS_SCHEMA = vol.Schema({vol.All(str, vol.Length(min=1, max=2)): SINGLE_XS_SCHEMA}) +_XS_SCHEMA = vol.Schema({vol.All(str, vol.Length(min=1, max=2)): _SINGLE_XS_SCHEMA}) + + +class XSSettings(dict): + """ + Container for holding multiple cross section settings based on their XSID. + + This is intended to be stored as part of a case settings and to be + used for cross section modeling within a run. + + Notes + ----- + This is a specialized dictionary that functions in a similar manner as a + defaultdict where if a key (i.e., XSID) is missing then a default will + be set. If a missing key is being added before the ``setDefaults`` method + is called then this will produce an error. + + This cannot just be a defaultdict because the creation of new cross + section settings are dependent on user settings. + """ + + def __init__(self, *args, **kwargs): + dict.__init__(self, *args, **kwargs) + self._blockRepresentation = None + self._validBlockTypes = None + + def __repr__(self): + return f"<{self.__class__.__name__} with XS IDs {self.keys()}>" + + def __getitem__(self, xsID): + """ + Return the stored settings of the same xs type and the lowest burnup group if they exist. + + Notes + ----- + 1. If `AA` and `AB` exist, but `AC` is created, then the intended behavior is that `AC` + settings will be set to the settings in `AA`. + + 2. If only `YZ' exists and `YA` is created, then the intended behavior is that + `YA` settings will NOT be set to the settings in `YZ`. + + 3. Requirements for using the existing cross section settings: + a. The existing XS ID must match the current XS ID. + b. The current xs burnup group must be larger than the lowest burnup group for the + existing XS ID + c. If 3a. and 3b. are not met, then the default cross section settings will be + set for the current XS ID + """ + if xsID in self: + return dict.__getitem__(self, xsID) + + xsType = xsID[0] + buGroup = xsID[1] + existingXsOpts = [ + xsOpt + for xsOpt in self.values() + if xsOpt.xsType == xsType and xsOpt.buGroup < buGroup + ] + + if not any(existingXsOpts): + return self._getDefault(xsID) + + else: + return sorted(existingXsOpts, key=lambda xsOpt: xsOpt.buGroup)[0] + + def setDefaults(self, blockRepresentation, validBlockTypes): + """ + Set defaults for current and future xsIDs based user settings. + + This must be delayed past read-time since the settings that effect this + may not be loaded yet and could still be at their own defaults when + this input is being processed. Thus, defaults are set at a later time. + + Parameters + ---------- + blockRepresentation : str + Valid options are provided in ``CrossSectionGroupManager.BLOCK_COLLECTIONS`` + + validBlockTypes : list of str or bool + This configures which blocks (by their type) that the cross section + group manager will merge together to create a representative block. If + set to ``None`` or ``True`` then all block types in the XS ID will be + considered. If this is set to ``False`` then a default of ["fuel"] will + be used. If this is set to a list of strings then the specific list will + be used. A typical input may be ["fuel"] to just consider the fuel blocks. + + See Also + -------- + armi.physics.neutronics.crossSectionGroupManager.CrossSectionGroupManager.interactBOL : calls this + """ + self._blockRepresentation = blockRepresentation + self._validBlockTypes = validBlockTypes + for _xsId, xsOpt in self.items(): + xsOpt.setDefaults( + blockRepresentation, + validBlockTypes, + ) + xsOpt.validate() + + def _getDefault(self, xsID): + """ + Process the optional ``crossSectionControl`` setting. + + This input allows users to override global defaults for specific cross section IDs (xsID). + + To simplify downstream handling of the various XS controls, we build a full data structure here + that should fully define the settings for each individual cross section ID. + """ + if self._blockRepresentation is None: + raise ValueError( + f"The defaults of {self} have not been set. Call ``setDefaults`` first " + "before attempting to add a new XS ID." + ) + + xsOpt = XSModelingOptions(xsID, geometry="0D") + xsOpt.setDefaults(self._blockRepresentation, self._validBlockTypes) + xsOpt.validate() + return xsOpt class XSModelingOptions: - """Advanced cross section modeling options for a particular XS ID.""" + """ + Cross section modeling options for a particular XS ID. + + Attributes + ---------- + xsID : str + Cross section ID that is two characters maximum (i.e., AA). + + geometry: str + The geometry modeling approximation for regions of the core with + this assigned xsID. This is required if the ``fileLocation`` + attribute is not provided. This cannot be set if the ``fileLocation`` + is provided. + + fileLocation: list of str + This should be a list of paths where the cross sections for this + xsID can be copied from. This is required if the ``geometry`` + attribute is not provided. This cannot be set if the ``geometry`` + is provided. + + validBlockTypes: str or None + This is a configuration option for how the cross section group manager + determines which blocks/regions to manage as part of the same collection + for the current xsID. If this is set to ``None`` then all blocks/regions + with the current xsID will be considered. + + blockRepresentation : str + This is a configuration option for how the cross section group manager + will select how to create a representative block based on the collection + within the same xsID. See: ``crossSectionGroupManager.BLOCK_COLLECTIONS``. + + driverID : str + This is a lattice physics configuration option used to determine which + representative block can be used as a "fixed source" driver for another + composition. This is particularly useful for non-fuel or highly subcritical + regions. + + criticalBuckling : bool + This is a lattice physics configuration option used to enable or disable + the critical buckling search option. + + nuclideReactionDriver : str + This is a lattice physics configuration option that is similar to the + ``driverID``, but rather than applying the source from a specific + representative block, the neutron source is taken from a single + nuclides fission spectrum (i.e., U235). This is particularly useful + for configuring SERPENT 2 lattice physics calculations. + + externalDriver : bool + This is a lattice physics configuration option that can be used + to determine if the fixed source problem is internally driven + or externally driven by the ``driverID`` region. Externally + driven means that the region will be placed on the outside of the + current xsID block/region. If this is False then the driver + region will be "inside" (i.e., an inner ring in a cylindrical + model). + + useHomogenizedBlockComposition : bool + This is a lattice physics configuration option that is useful for + modeling spatially dependent problems (i.e., 1D/2D). If this is + True then the representative block for the current xsID will be + be a homogenized region. If this is False then the block will be + represented in the geometry type selected. This is mainly used for + 1D cylindrical problems. + + numInternalRings : int + This is a lattice physics configuration option that is used to + specify the number of grid-based rings for the representative block. + + numExternalRings : int + This is a lattice physics configuration option that is used to + specify the number of grid-based rings for the driver block. + + mergeIntoClad : list of str + This is a lattice physics configuration option that is a list of component + names to merge into a "clad" component. This is highly-design specific + and is sometimes used to merge a "gap" or low-density region into + a "clad" region to avoid numerical issues. + + meshSubdivisionsPerCm : float + This is a lattice physics configuration option that can be used to control + subregion meshing of the representative block in 1D problems. + + Notes + ----- + Not all default attributes may be useful for your specific application and you may + require other types of configuration options. These are provided as examples since + the base ``latticePhysicsInterface`` does not implement models that use these. For + additional options, consider subclassing the base ``Setting`` object and using this + model as a template. + """ def __init__( self, xsID, geometry=None, + fileLocation=None, + validBlockTypes=None, blockRepresentation=None, driverID=None, criticalBuckling=None, nuclideReactionDriver=None, - validBlockTypes=None, - externalDriver=True, - useHomogenizedBlockComposition=False, - numInternalRings=1, - numExternalRings=1, + externalDriver=None, + useHomogenizedBlockComposition=None, + numInternalRings=None, + numExternalRings=None, mergeIntoClad=None, - fileLocation=None, meshSubdivisionsPerCm=None, ): self.xsID = xsID self.geometry = geometry + self.fileLocation = fileLocation + self.validBlockTypes = validBlockTypes self.blockRepresentation = blockRepresentation + + # These are application specific, feel free use them + # in your own lattice physics plugin(s). self.driverID = driverID self.criticalBuckling = criticalBuckling self.nuclideReactionDriver = nuclideReactionDriver - self.validBlockTypes = validBlockTypes self.externalDriver = externalDriver self.useHomogenizedBlockComposition = useHomogenizedBlockComposition self.numInternalRings = numInternalRings self.numExternalRings = numExternalRings self.mergeIntoClad = mergeIntoClad - self.fileLocation = fileLocation self.meshSubdivisionsPerCm = meshSubdivisionsPerCm + def __repr__(self): + if self.isPregenerated: + suffix = f"Pregenerated: {self.isPregenerated}" + else: + suffix = f"Geometry Model: {self.geometry}" + return f"<{self.__class__.__name__}, XSID: {self.xsID}, {suffix}>" + + def __iter__(self): + return iter(self.__dict__.items()) + @property def xsType(self): """Return the single-char cross section type indicator.""" @@ -125,203 +397,251 @@ def isPregenerated(self): """True if this points to a pre-generated XS file.""" return self.fileLocation is not None - def setDefaults(self, blockRepresentation, validBlockTypes): - # TODO: explain this logic - validBlockTypes = None if validBlockTypes else ["fuel"] - if self.geometry == "0D": - defaults = dict( - criticalBuckling=True, - numExternalRings=None, - blockRepresentation=blockRepresentation, - validBlockTypes=validBlockTypes, + def serialize(self): + """Return as a dictionary without ``xsID`` and with ``None`` values excluded.""" + doNotSerialize = ["xsID"] + return { + key: val + for key, val in self + if key not in doNotSerialize and val is not None + } + + def validate(self): + """ + Performs validation checks on the inputs and provides warnings for option inconsistencies. + + Raises + ------ + ValueError + When the mutually exclusive ``fileLocation`` and ``geometry`` attributes + are provided or when neither are provided. + """ + # Check for valid inputs when the file location is supplied. + if self.fileLocation is None and self.geometry is None: + raise ValueError(f"{self} is missing a geometry input or a file location.") + + if self.fileLocation is not None and self.geometry is not None: + raise ValueError( + f"Either file location or geometry inputs in {self} must be given, but not both. " + "Remove one or the other." ) - elif self.geometry == "1D slab": - defaults = dict( - criticalBuckling=False, - mergeIntoClad=[], - blockRepresentation=blockRepresentation, - validBlockTypes=validBlockTypes, + + invalids = [] + if self.fileLocation is not None: + for var, val in self: + # Skip these attributes since they are valid options + # when the ``fileLocation`` attribute`` is set. + if var in [CONF_XSID, CONF_FILE_LOCATION, CONF_BLOCK_REPRESENTATION]: + continue + if val is not None: + invalids.append((var, val)) + + if invalids: + runLog.warning( + f"The following inputs in {self} are not valid when the file location is set:" ) - elif self.geometry == "1D cylinder": - defaults = dict( - criticalBuckling=False, - mergeIntoClad=[], - blockRepresentation=blockRepresentation, - numExternalRings=1, - validBlockTypes=validBlockTypes, + for var, val in invalids: + runLog.warning(f"\tAttribute: {var}, Value: {val}") + + # Check for valid inputs when the geometry is supplied. + invalids = [] + if self.geometry is not None: + validOptions = _VALID_INPUTS_BY_GEOMETRY_TYPE[self.geometry] + for var, val in self: + if var not in validOptions and val is not None: + invalids.append((var, val)) + + if invalids: + runLog.warning( + f"The following inputs in {self} are not valid when `{self.geometry}` geometry type is set:" ) - elif self.geometry == "2D hex": - defaults = dict( - blockRepresentation=blockRepresentation, - numExternalRings=1, - externalDriver=True, + for var, val in invalids: + runLog.warning(f"\tAttribute: {var}, Value: {val}") + runLog.warning( + f"The valid options for the `{self.geometry}` geometry are: {validOptions}" ) + + def setDefaults(self, blockRepresentation, validBlockTypes): + """ + This sets the defaults based on some recommended values based on the geometry type. + + Parameters + ---------- + blockRepresentation : str + Valid options are provided in ``CrossSectionGroupManager.BLOCK_COLLECTIONS`` + + validBlockTypes : list of str or bool + This configures which blocks (by their type) that the cross section + group manager will merge together to create a representative block. If + set to ``None`` or ``True`` then all block types in the XS ID will be + considered. If this is set to ``False`` then a default of ["fuel"] will + be used. If this is set to a list of strings then the specific list will + be used. A typical input may be ["fuel"] to just consider the fuel blocks. + + Notes + ----- + These defaults are application-specific and design specific. They are included + to provide an example and are tuned to fit the internal needs of TerraPower. Consider + a separate implementation/subclass if you would like different behavior. + """ + if type(validBlockTypes) == bool: + validBlockTypes = None if validBlockTypes else ["fuel"] else: - defaults = dict( - blockRepresentation=blockRepresentation, validBlockTypes=validBlockTypes - ) + validBlockTypes = validBlockTypes + + defaults = {} + if self.isPregenerated: + defaults = { + CONF_FILE_LOCATION: self.fileLocation, + CONF_BLOCK_REPRESENTATION: blockRepresentation, + } + + elif self.geometry == "0D": + defaults = { + CONF_GEOM: "0D", + CONF_BUCKLING: True, + CONF_DRIVER: "", + CONF_BLOCK_REPRESENTATION: blockRepresentation, + CONF_BLOCKTYPES: validBlockTypes, + } + elif self.geometry == "1D slab": + defaults = { + CONF_GEOM: "1D slab", + CONF_MESH_PER_CM: 1.0, + CONF_BLOCK_REPRESENTATION: blockRepresentation, + CONF_BLOCKTYPES: validBlockTypes, + } + elif self.geometry == "1D cylinder": + defaults = { + CONF_GEOM: "1D cylinder", + CONF_DRIVER: "", + CONF_MERGE_INTO_CLAD: ["gap"], + CONF_MESH_PER_CM: 1.0, + CONF_INTERNAL_RINGS: 0, + CONF_EXTERNAL_RINGS: 1, + CONF_HOMOGBLOCK: False, + CONF_BLOCK_REPRESENTATION: blockRepresentation, + CONF_BLOCKTYPES: validBlockTypes, + } + elif self.geometry == "2D hex": + defaults = { + CONF_GEOM: "2D hex", + CONF_BUCKLING: False, + CONF_EXTERNAL_DRIVER: True, + CONF_DRIVER: "", + CONF_REACTION_DRIVER: None, + CONF_EXTERNAL_RINGS: 1, + CONF_BLOCK_REPRESENTATION: blockRepresentation, + } for attrName, defaultValue in defaults.items(): currentValue = getattr(self, attrName) if currentValue is None: + runLog.extra( + f"Applying default value {defaultValue} to {attrName} to {self}." + ) setattr(self, attrName, defaultValue) - self._checkSettings() - - def _checkSettings(self): - """Fail fast if cross section settings will cause lattice physics to fail.""" - geomsThatNeedMeshSize = ("1D slab", "1D cylinder") - if self.geometry in geomsThatNeedMeshSize: - if self.meshSubdivisionsPerCm is None: - raise ValueError( - "{} geometry requires `mesh points per cm` to be defined in cross sections.".format( - self.geometry - ) - ) - if self.criticalBuckling != False: - raise ValueError( - "{} geometry cannot model critical buckling. Please disable".format( - self.geometry - ) - ) + self.validate() -class XSSettingDef(Setting): +def serializeXSSettings(xsSettingsDict: Union[XSSettings, Dict]) -> Dict[str, Dict]: """ - The setting object with custom serialization. + Return a serialized form of the ``XSSettings`` as a dictionary. - Note that the VALUE of the setting will be a XSSettingValue object. + Notes + ----- + Attributes that are not set (i.e., set to None) will be skipped. """ + if not isinstance(xsSettingsDict, dict): + raise TypeError(f"Expected a dictionary for {xsSettingsDict}") - def _load(self, inputVal): - """Read a dict of input, validate it, and populate this with new instances.""" - inputVal = XS_SCHEMA(inputVal) - for xsID, inputParams in inputVal.items(): - self._value[xsID] = XSModelingOptions(xsID, **inputParams) - return self._value - - def dump(self): - """ - Dump serialized XSModelingOptions. + output = {} + for xsID, xsOpts in xsSettingsDict.items(): - This is used when saving settings to a file. Conveniently, - YAML libs can load/dump simple objects like this out of the box. + # Setting the value to an empty dictionary + # if it is set to a None or an empty + # dictionary. + if not xsOpts: + continue - We massage the data on its way out for user convenience, leaving None values out - and leaving the special ``xsID`` value out. - """ - output = self._serialize(self._value) - output = XS_SCHEMA(output) # validate on the way out too - return output - - def setValue(self, val): - """ - Set value of setting to val. + if isinstance(xsOpts, XSModelingOptions): + xsIDVals = xsOpts.serialize() - Since this is a custom serializable setting, we allow users - to pass in either a ``XSModelingOptions`` object itself - or a dictionary representation of one. - """ - try: - if isinstance(list(val.values())[0], XSModelingOptions): - val = self._serialize(val) - except TypeError: - # value is not a dict, may be a CommentedMapValuesView or related; serialization - # not required - pass - - Setting.setValue(self, val) - - @staticmethod - def _serialize(value): - output = {} - for xsID, val in value.items(): + elif isinstance(xsOpts, dict): xsIDVals = { config: confVal - for config, confVal in val.__dict__.items() + for config, confVal in xsOpts.items() if config != "xsID" and confVal is not None } - output[xsID] = xsIDVals - return output + else: + raise TypeError( + f"{xsOpts} was expected to be a ``dict`` or " + f"``XSModelingOptions`` options type but is type {type(xsOpts)}" + ) + output[str(xsID)] = xsIDVals + return output -class XSSettings(dict): - """ - The container that holds the multiple individual XS settings for different ids. - This is what the value of the cs setting is set to. It handles reading/writing the settings - to file as well as some other special behavior. +class XSSettingDef(Setting): """ + Custom setting object to manage the cross section dictionary-like inputs. - def __getitem__(self, xsID): - """ - Return the stored settings of the same xs type and the lowest burnup group if they exist. - - Notes - ----- - 1. If `AA` and `AB` exist, but `AC` is created, then the intended behavior is that `AC` - settings will be set to the settings in `AA`. - - 2. If only `YZ' exists and `YA` is created, then the intended behavior is that - `YA` settings will NOT be set to the settings in `YZ`. - - 3. Requirements for using the existing cross section settings: - a. The existing XS ID must match the current XS ID. - b. The current xs burnup group must be larger than the lowest burnup group for the - existing XS ID - c. If 3a. and 3b. are not met, then the default cross section settings will be - set for the current XS ID - """ - if xsID in self: - return dict.__getitem__(self, xsID) - - xsType = xsID[0] - buGroup = xsID[1] - existingXsOpts = [ - xsOpt - for xsOpt in self.values() - if xsOpt.xsType == xsType and xsOpt.buGroup < buGroup - ] - - if not any(existingXsOpts): - return self._getDefault(xsID) - - else: - return sorted(existingXsOpts, key=lambda xsOpt: xsOpt.buGroup)[0] - - def setDefaults(self, cs): - """ - Set defaults for current and future xsIDs based on cs. - - This must be delayed past read-time since the settings that effect this - may not be loaded yet and could still be at their own defaults when - this input is being processed. Thus, defaults are set at a later time. + Notes + ----- + This uses the ``xsSettingsValidator`` schema to validate the inputs + and will automatically coerce the value into a ``XSSettings`` dictionary. + """ - See Also - -------- - armi.physics.neutronics.crossSectionGroupManager.CrossSectionGroupManager.interactBOL : calls this - """ - self._xsBlockRepresentation = cs["xsBlockRepresentation"] - self._disableBlockTypeExclusionInXsGeneration = cs[ - "disableBlockTypeExclusionInXsGeneration" - ] - for xsId, xsOpt in self.items(): - xsOpt.setDefaults( - cs["xsBlockRepresentation"], - cs["disableBlockTypeExclusionInXsGeneration"], - ) + def __init__(self, name): + description = "Data structure defining how cross sections are created" + label = "Cross section control" + default = XSSettings() + options = None + schema = xsSettingsValidator + enforcedOptions = False + subLabels = None + isEnvironment = False + oldNames = None + Setting.__init__( + self, + name, + default, + description, + label, + options, + schema, + enforcedOptions, + subLabels, + isEnvironment, + oldNames, + ) - def _getDefault(self, xsID): - """ - Process the optional 'cross section' input field. + def dump(self): + """Return a serialized version of the ``XSSetting`` object.""" + return serializeXSSettings(self._value) - This input allows users to override global defaults for specific cross section IDs (xsID). - To simplify downstream handling of the various XS controls, we build a full data structure here - that should fully define the settings for each individual cross section ID. - """ - xsOpt = XSModelingOptions(xsID, geometry="0D") - xsOpt.setDefaults( - self._xsBlockRepresentation, self._disableBlockTypeExclusionInXsGeneration - ) - return xsOpt +def xsSettingsValidator(xsSettingsDict: Dict[str, Dict]) -> XSSettings: + """ + Returns a ``XSSettings`` object if validation is successful. + + Notes + ----- + This provides two levels of checks. The first check is that the attributes + provided as user input contains the correct key/values and the values are + of the correct type. The second check uses the ``XSModelingOptions.validate`` + method to check for input inconsistencies and provides warnings if there + are any issues. + """ + xsSettingsDict = serializeXSSettings(xsSettingsDict) + xsSettingsDict = _XS_SCHEMA(xsSettingsDict) + vals = XSSettings() + for xsID, inputParams in xsSettingsDict.items(): + if not inputParams: + continue + xsOpt = XSModelingOptions(xsID, **inputParams) + xsOpt.validate() + vals[xsID] = xsOpt + return vals diff --git a/armi/physics/neutronics/fissionProductModel/lumpedFissionProduct.py b/armi/physics/neutronics/fissionProductModel/lumpedFissionProduct.py index 570f9d145..ea1b816e0 100644 --- a/armi/physics/neutronics/fissionProductModel/lumpedFissionProduct.py +++ b/armi/physics/neutronics/fissionProductModel/lumpedFissionProduct.py @@ -258,7 +258,7 @@ def getLanthanideFraction(self): return totalLanthanides / self.getTotalYield() def printDensities(self, lfpDens): - """Print densities of nuclides given a LFP density. """ + """Print densities of nuclides given a LFP density.""" for n in sorted(self.keys()): runLog.info("{0:6s} {1:.7E}".format(n.name, lfpDens * self[n])) diff --git a/armi/physics/neutronics/fissionProductModel/tests/test_lumpedFissionProduct.py b/armi/physics/neutronics/fissionProductModel/tests/test_lumpedFissionProduct.py index 3944eb733..3b34027dd 100644 --- a/armi/physics/neutronics/fissionProductModel/tests/test_lumpedFissionProduct.py +++ b/armi/physics/neutronics/fissionProductModel/tests/test_lumpedFissionProduct.py @@ -41,13 +41,13 @@ def getDummyLFPFile(): class TestFissionProductDefinitionFile(unittest.TestCase): - """ Test of the fission product model """ + """Test of the fission product model""" def setUp(self): self.fpd = getDummyLFPFile() def testCreateLFPs(self): - """ Test of the fission product model creation """ + """Test of the fission product model creation""" lfps = self.fpd.createLFPsFromFile() xe135 = nuclideBases.fromName("XE135") self.assertEqual(len(lfps), 3) @@ -57,7 +57,7 @@ def testCreateLFPs(self): class TestLumpedFissionProduct(unittest.TestCase): - """ Test of the lumped fission product yields """ + """Test of the lumped fission product yields""" def setUp(self): self.fpd = lumpedFissionProduct.FissionProductDefinitionFile( @@ -65,7 +65,7 @@ def setUp(self): ) def test_setGasRemovedFrac(self): - """ Test of the set gas removal fraction """ + """Test of the set gas removal fraction""" lfp = self.fpd.createSingleLFPFromFile("LFP38") xe135 = nuclideBases.fromName("XE135") gas1 = lfp[xe135] @@ -74,7 +74,7 @@ def test_setGasRemovedFrac(self): self.assertAlmostEqual(gas1 * 0.75, gas2) def test_getYield(self): - """ Test of the yield of a fission product """ + """Test of the yield of a fission product""" xe135 = nuclideBases.fromName("XE135") lfp = self.fpd.createSingleLFPFromFile("LFP39") lfp[xe135] = 3 @@ -95,7 +95,7 @@ def test_getExpandedMass(self): self.assertEqual(massVector.get(xe135), 0.99) def test_getGasFraction(self): - """ Test of the get gas removal fraction """ + """Test of the get gas removal fraction""" lfp = self.fpd.createSingleLFPFromFile("LFP35") frac = lfp.getGasFraction() self.assertGreater(frac, 0.0) @@ -107,7 +107,7 @@ def test_printDensities(self): lfp.printDensities(10.0) def test_getLanthanideFraction(self): - """ Test of the lanthanide fraction function """ + """Test of the lanthanide fraction function""" lfp = self.fpd.createSingleLFPFromFile("LFP35") frac = lfp.getLanthanideFraction() self.assertGreater(frac, 0.0) @@ -115,20 +115,20 @@ def test_getLanthanideFraction(self): class TestLumpedFissionProductCollection(unittest.TestCase): - """ Test of the fission product collection """ + """Test of the fission product collection""" def setUp(self): fpd = lumpedFissionProduct.FissionProductDefinitionFile(io.StringIO(LFP_TEXT)) self.lfps = fpd.createLFPsFromFile() def test_getAllFissionProductNames(self): - """ Test to ensure the fission product names are present """ + """Test to ensure the fission product names are present""" names = self.lfps.getAllFissionProductNames() self.assertIn("XE135", names) self.assertIn("KR85", names) def test_getAllFissionProductNuclideBases(self): - """ Test to ensure the fission product nuclide bases are present """ + """Test to ensure the fission product nuclide bases are present""" clideBases = self.lfps.getAllFissionProductNuclideBases() xe135 = nuclideBases.fromName("XE135") kr85 = nuclideBases.fromName("KR85") @@ -140,7 +140,7 @@ def test_getGasRemovedFrac(self): self.assertEqual(val, 0.0) def test_duplicate(self): - """ Test to ensure that when we duplicate, we don't adjust the original file """ + """Test to ensure that when we duplicate, we don't adjust the original file""" newLfps = self.lfps.duplicate() ba = nuclideBases.fromName("XE135") lfp1 = self.lfps["LFP39"] @@ -184,7 +184,7 @@ def test_getMassFrac(self): class TestMo99LFP(unittest.TestCase): - """ Test of the fission product model from Mo99 """ + """Test of the fission product model from Mo99""" def setUp(self): self.lfps = ( @@ -192,7 +192,7 @@ def setUp(self): ) # pylint: disable=protected-access def test_getAllFissionProductNames(self): - """ Test to ensure that Mo99 is present, but other FP are not """ + """Test to ensure that Mo99 is present, but other FP are not""" names = self.lfps.getAllFissionProductNames() self.assertIn("MO99", names) self.assertNotIn("KR85", names) @@ -200,7 +200,7 @@ def test_getAllFissionProductNames(self): class TestExpandCollapse(unittest.TestCase): - """ Test of the ability of the fission product file to expand from the LFPs """ + """Test of the ability of the fission product file to expand from the LFPs""" def test_expand(self): diff --git a/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py b/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py index 7d7950f83..4e9c14ea1 100644 --- a/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py +++ b/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py @@ -418,11 +418,8 @@ def _adjustPuFissileDensity(self, nucDensities): new = (minFrac * (hm - old) + old - fiss) / (1 - minFrac) nucDensities[pu239] = (new, temp, msg) runLog.warning( - "Adjusting Pu-239 number densities in {} from {} to {} to meet minimum fissile fraction " - "of {}.\nNote: This modeling approximation will be deprecated soon so it is recommended to " - "drive this composition with an external source.".format( - self.block, old, new, minFrac - ) + f"Adjusting Pu-239 number densities in {self.block} from {old} to {new} " + f"to meet minimum fissile fraction of {minFrac}." ) return nucDensities diff --git a/armi/physics/neutronics/tests/test_crossSectionSettings.py b/armi/physics/neutronics/tests/test_crossSectionSettings.py index 503571abd..2096a9316 100644 --- a/armi/physics/neutronics/tests/test_crossSectionSettings.py +++ b/armi/physics/neutronics/tests/test_crossSectionSettings.py @@ -19,11 +19,13 @@ import voluptuous as vol from armi import settings +from armi.settings import caseSettings from armi.physics.neutronics.crossSectionSettings import XSModelingOptions from armi.physics.neutronics.crossSectionSettings import XSSettings from armi.physics.neutronics.crossSectionSettings import XSSettingDef -from armi.physics.neutronics.crossSectionSettings import SINGLE_XS_SCHEMA +from armi.physics.neutronics.crossSectionSettings import xsSettingsValidator from armi.physics.neutronics.tests.test_neutronicsPlugin import XS_EXAMPLE +from armi.physics.neutronics.const import CONF_CROSS_SECTION class TestCrossSectionSettings(unittest.TestCase): @@ -40,12 +42,15 @@ def test_crossSections(self): self.assertEqual("Median", xsModel.blockRepresentation) def test_pregeneratedCrossSections(self): + cs = settings.Settings() xs = XSSettings() xa = XSModelingOptions("XA", fileLocation=["ISOXA"]) xs["XA"] = xa self.assertEqual(["ISOXA"], xa.fileLocation) self.assertNotIn("XB", xs) - xs.setDefaults(settings.Settings()) + xs.setDefaults( + cs["xsBlockRepresentation"], cs["disableBlockTypeExclusionInXsGeneration"] + ) # Check that the file location of 'XB' still points to the same file location as 'XA'. self.assertEqual(xa, xs["XB"]) @@ -55,18 +60,24 @@ def test_homogeneousXsDefaultSettingAssignment(self): This is used when user hasn't specified anything. """ + cs = settings.Settings() xsModel = XSSettings() - xsModel.setDefaults(settings.Settings()) + xsModel.setDefaults( + cs["xsBlockRepresentation"], cs["disableBlockTypeExclusionInXsGeneration"] + ) self.assertNotIn("YA", xsModel) self.assertEqual(xsModel["YA"].geometry, "0D") self.assertEqual(xsModel["YA"].criticalBuckling, True) def test_setDefaultSettingsByLowestBuGroupHomogeneous(self): # Initialize some micro suffix in the cross sections + cs = settings.Settings() xs = XSSettings() jd = XSModelingOptions("JD", geometry="0D", criticalBuckling=False) xs["JD"] = jd - xs.setDefaults(settings.Settings()) + xs.setDefaults( + cs["xsBlockRepresentation"], cs["disableBlockTypeExclusionInXsGeneration"] + ) self.assertIn("JD", xs) @@ -85,15 +96,18 @@ def test_setDefaultSettingsByLowestBuGroupHomogeneous(self): def test_setDefaultSettingsByLowestBuGroupOneDimensional(self): # Initialize some micro suffix in the cross sections + cs = settings.Settings() xsModel = XSSettings() rq = XSModelingOptions( "RQ", - geometry="1D slab", + geometry="1D cylinder", blockRepresentation="Average", meshSubdivisionsPerCm=1.0, ) xsModel["RQ"] = rq - xsModel.setDefaults(settings.Settings()) + xsModel.setDefaults( + cs["xsBlockRepresentation"], cs["disableBlockTypeExclusionInXsGeneration"] + ) # Check that new micro suffix `RY` with higher burn-up group gets assigned the same settings as `RQ` self.assertNotIn("RY", xsModel) @@ -109,22 +123,35 @@ def test_setDefaultSettingsByLowestBuGroupOneDimensional(self): def test_optionalKey(self): """Test that optional key shows up with default value.""" + cs = settings.Settings() xsModel = XSSettings() - da = XSModelingOptions("DA", geometry="1D slab", meshSubdivisionsPerCm=1.0) + da = XSModelingOptions("DA", geometry="1D cylinder", meshSubdivisionsPerCm=1.0) xsModel["DA"] = da - xsModel.setDefaults(settings.Settings()) - self.assertEqual(xsModel["DA"].mergeIntoClad, []) - self.assertEqual(xsModel["DA"].criticalBuckling, False) + xsModel.setDefaults( + cs["xsBlockRepresentation"], cs["disableBlockTypeExclusionInXsGeneration"] + ) + self.assertEqual(xsModel["DA"].mergeIntoClad, ["gap"]) + self.assertEqual(xsModel["DA"].meshSubdivisionsPerCm, 1.0) def test_badCrossSections(self): + with self.assertRaises(TypeError): + # This will fail because it is not the required + # Dict[str: Dict] structure + xsSettingsValidator({"geometry": "4D"}) + with self.assertRaises(vol.error.MultipleInvalid): - SINGLE_XS_SCHEMA({"xsID": "HI", "geometry": "4D"}) + # This will fail because it has an invalid type for ``driverID`` + xsSettingsValidator({"AA": {"driverId": 0.0}}) with self.assertRaises(vol.error.MultipleInvalid): - SINGLE_XS_SCHEMA({"xsID": "HI", "driverId": 0.0}) + # This will fail because it has an invalid value for + # the ``blockRepresentation`` + xsSettingsValidator({"AA": {"blockRepresentation": "Invalid"}}) with self.assertRaises(vol.error.MultipleInvalid): - SINGLE_XS_SCHEMA({"xsID": "HI", "blockRepresentation": "Invalid"}) + # This will fail because the ``xsID`` is not one or two + # characters + xsSettingsValidator({"AAA": {"blockRepresentation": "Average"}}) class Test_XSSettings(unittest.TestCase): @@ -132,8 +159,8 @@ def test_yamlIO(self): """Ensure we can read/write this custom setting object to yaml""" yaml = YAML() inp = yaml.load(io.StringIO(XS_EXAMPLE)) - xs = XSSettingDef("TestSetting", XSSettings()) - xs._load(inp) # pylint: disable=protected-access + xs = XSSettingDef("TestSetting") + xs.setValue(inp) self.assertEqual(xs.value["BA"].geometry, "1D slab") outBuf = io.StringIO() output = xs.dump() @@ -142,6 +169,162 @@ def test_yamlIO(self): inp2 = yaml.load(outBuf) self.assertEqual(inp.keys(), inp2.keys()) + def test_caseSettings(self): + """ + Test the setting of the cross section setting using the case settings object. + + Notes + ----- + The purpose of this test is to ensure that the cross sections sections can + be removed from an existing case settings object once they have been set. + """ + + def _setInitialXSSettings(): + cs = caseSettings.Settings() + cs[CONF_CROSS_SECTION] = XSSettings() + cs[CONF_CROSS_SECTION]["AA"] = XSModelingOptions("AA", geometry="0D") + cs[CONF_CROSS_SECTION]["BA"] = XSModelingOptions("BA", geometry="0D") + self.assertIn("AA", cs[CONF_CROSS_SECTION]) + self.assertIn("BA", cs[CONF_CROSS_SECTION]) + self.assertNotIn("CA", cs[CONF_CROSS_SECTION]) + self.assertNotIn("DA", cs[CONF_CROSS_SECTION]) + return cs + + cs = _setInitialXSSettings() + cs[CONF_CROSS_SECTION] = {"AA": {}, "BA": {}} + self.assertDictEqual(cs[CONF_CROSS_SECTION], {}) + self.assertTrue(isinstance(cs[CONF_CROSS_SECTION], XSSettings)) + + # Produce an error if the setting is set to + # a None value + cs = _setInitialXSSettings() + with self.assertRaises(TypeError): + cs[CONF_CROSS_SECTION] = None + + cs = _setInitialXSSettings() + cs[CONF_CROSS_SECTION] = {"AA": None, "BA": {}} + self.assertDictEqual(cs[CONF_CROSS_SECTION], {}) + + # Test that a new XS setting can be added to an existing + # caseSetting using the ``XSModelingOptions`` or using + # a dictionary. + cs = _setInitialXSSettings() + cs[CONF_CROSS_SECTION].update( + {"CA": XSModelingOptions("CA", geometry="0D"), "DA": {"geometry": "0D"}} + ) + self.assertIn("AA", cs[CONF_CROSS_SECTION]) + self.assertIn("BA", cs[CONF_CROSS_SECTION]) + self.assertIn("CA", cs[CONF_CROSS_SECTION]) + self.assertIn("DA", cs[CONF_CROSS_SECTION]) + + # Clear out the settings by setting the value to a None. + # This will be interpreted as a empty dictionary. + cs[CONF_CROSS_SECTION] = {} + self.assertDictEqual(cs[CONF_CROSS_SECTION], {}) + self.assertTrue(isinstance(cs[CONF_CROSS_SECTION], XSSettings)) + + # This will fail because the ``setDefaults`` method on the + # ``XSSettings`` has not yet been called. + with self.assertRaises(ValueError): + cs[CONF_CROSS_SECTION]["AA"] + + cs[CONF_CROSS_SECTION].setDefaults( + blockRepresentation=cs["xsBlockRepresentation"], + validBlockTypes=cs["disableBlockTypeExclusionInXsGeneration"], + ) + + cs[CONF_CROSS_SECTION]["AA"] + self.assertEqual(cs[CONF_CROSS_SECTION]["AA"].geometry, "0D") + + def test_csBlockRepresentation(self): + """ + Test that the XS block representation is applied globally, + but only to XS modeling options where the blockRepresentation + has not already been assigned. + """ + cs = caseSettings.Settings() + cs["xsBlockRepresentation"] = "FluxWeightedAverage" + cs[CONF_CROSS_SECTION] = XSSettings() + cs[CONF_CROSS_SECTION]["AA"] = XSModelingOptions("AA", geometry="0D") + cs[CONF_CROSS_SECTION]["BA"] = XSModelingOptions( + "BA", geometry="0D", blockRepresentation="Average" + ) + + self.assertEqual(cs[CONF_CROSS_SECTION]["AA"].blockRepresentation, None) + self.assertEqual(cs[CONF_CROSS_SECTION]["BA"].blockRepresentation, "Average") + + cs[CONF_CROSS_SECTION].setDefaults( + cs["xsBlockRepresentation"], cs["disableBlockTypeExclusionInXsGeneration"] + ) + + self.assertEqual( + cs[CONF_CROSS_SECTION]["AA"].blockRepresentation, "FluxWeightedAverage" + ) + self.assertEqual(cs[CONF_CROSS_SECTION]["BA"].blockRepresentation, "Average") + + def test_csBlockRepresentationFileLocation(self): + """ + Test that default blockRepresentation is applied correctly to a + XSModelingOption that has the ``fileLocation`` attribute defined. + """ + cs = caseSettings.Settings() + cs["xsBlockRepresentation"] = "FluxWeightedAverage" + cs[CONF_CROSS_SECTION] = XSSettings() + cs[CONF_CROSS_SECTION]["AA"] = XSModelingOptions("AA", fileLocation=[]) + + # Check FluxWeightedAverage + cs[CONF_CROSS_SECTION].setDefaults( + cs["xsBlockRepresentation"], cs["disableBlockTypeExclusionInXsGeneration"] + ) + self.assertEqual( + cs[CONF_CROSS_SECTION]["AA"].blockRepresentation, "FluxWeightedAverage" + ) + + # Check Average + cs["xsBlockRepresentation"] = "Average" + cs[CONF_CROSS_SECTION]["AA"] = XSModelingOptions("AA", fileLocation=[]) + cs[CONF_CROSS_SECTION].setDefaults( + cs["xsBlockRepresentation"], cs["disableBlockTypeExclusionInXsGeneration"] + ) + self.assertEqual(cs[CONF_CROSS_SECTION]["AA"].blockRepresentation, "Average") + + # Check Median + cs["xsBlockRepresentation"] = "Average" + cs[CONF_CROSS_SECTION]["AA"] = XSModelingOptions( + "AA", fileLocation=[], blockRepresentation="Median" + ) + cs[CONF_CROSS_SECTION].setDefaults( + cs["xsBlockRepresentation"], cs["disableBlockTypeExclusionInXsGeneration"] + ) + self.assertEqual(cs[CONF_CROSS_SECTION]["AA"].blockRepresentation, "Median") + + def test_xsSettingsSetDefault(self): + """Test the configuration options of the ``setDefaults`` method.""" + cs = caseSettings.Settings() + cs["xsBlockRepresentation"] = "FluxWeightedAverage" + cs[CONF_CROSS_SECTION].setDefaults( + blockRepresentation=cs["xsBlockRepresentation"], validBlockTypes=None + ) + self.assertEqual(cs[CONF_CROSS_SECTION]["AA"].validBlockTypes, None) + + cs[CONF_CROSS_SECTION].setDefaults( + blockRepresentation=cs["xsBlockRepresentation"], validBlockTypes=True + ) + self.assertEqual(cs[CONF_CROSS_SECTION]["AA"].validBlockTypes, None) + + cs[CONF_CROSS_SECTION].setDefaults( + blockRepresentation=cs["xsBlockRepresentation"], validBlockTypes=False + ) + self.assertEqual(cs[CONF_CROSS_SECTION]["AA"].validBlockTypes, ["fuel"]) + + cs[CONF_CROSS_SECTION].setDefaults( + blockRepresentation=cs["xsBlockRepresentation"], + validBlockTypes=["control", "fuel", "plenum"], + ) + self.assertEqual( + cs[CONF_CROSS_SECTION]["AA"].validBlockTypes, ["control", "fuel", "plenum"] + ) + if __name__ == "__main__": # sys.argv = ["", "TestCrossSectionSettings.test_badCrossSections"] diff --git a/armi/physics/neutronics/tests/test_cross_section_manager.py b/armi/physics/neutronics/tests/test_cross_section_manager.py index 7de72098d..9c183b45b 100644 --- a/armi/physics/neutronics/tests/test_cross_section_manager.py +++ b/armi/physics/neutronics/tests/test_cross_section_manager.py @@ -169,7 +169,19 @@ def test_ComponentAverageRepBlock(self): 1D cases the order of the components matters. """ xsgm = self.o.getInterface("xsGroups") + + for _xsID, xsOpt in self.o.cs["crossSectionControl"].items(): + self.assertEqual(xsOpt.blockRepresentation, None) + xsgm.interactBOL() + + # Check that the correct defaults are propagated after the interactBOL + # from the cross section group manager is called. + for _xsID, xsOpt in self.o.cs["crossSectionControl"].items(): + self.assertEqual( + xsOpt.blockRepresentation, self.o.cs["xsBlockRepresentation"] + ) + xsgm.createRepresentativeBlocks() representativeBlockList = list(xsgm.representativeBlocks.values()) representativeBlockList.sort(key=lambda repB: repB.getMass() / repB.getVolume()) diff --git a/armi/physics/neutronics/tests/test_neutronicsPlugin.py b/armi/physics/neutronics/tests/test_neutronicsPlugin.py index 43234be69..ba7dba593 100644 --- a/armi/physics/neutronics/tests/test_neutronicsPlugin.py +++ b/armi/physics/neutronics/tests/test_neutronicsPlugin.py @@ -35,7 +35,6 @@ blockRepresentation: Median BA: geometry: 1D slab - criticalBuckling: false blockRepresentation: Median """ diff --git a/armi/reactor/assemblyLists.py b/armi/reactor/assemblyLists.py index 1c2ef6dcc..848dce22e 100644 --- a/armi/reactor/assemblyLists.py +++ b/armi/reactor/assemblyLists.py @@ -195,7 +195,7 @@ def count(self): class SpentFuelPool(AssemblyList): - """A place to put assemblies when they've been discharged. Can tell you inventory stats, etc. """ + """A place to put assemblies when they've been discharged. Can tell you inventory stats, etc.""" def report(self): title = "{0} Report".format(self.name) @@ -225,7 +225,7 @@ def report(self): class ChargedFuelPool(AssemblyList): - """A place to put boosters so you can see how much you added. Can tell you inventory stats, etc. """ + """A place to put boosters so you can see how much you added. Can tell you inventory stats, etc.""" def report(self): title = "{0} Report".format(self.name) diff --git a/armi/reactor/components/__init__.py b/armi/reactor/components/__init__.py index 24c039bf8..69aa63838 100644 --- a/armi/reactor/components/__init__.py +++ b/armi/reactor/components/__init__.py @@ -91,17 +91,17 @@ def _removeDimensionNameSpaces(attrs): class NullComponent(Component): - r"""returns zero for all dimensions. is none. """ + r"""returns zero for all dimensions. is none.""" def __cmp__(self, other): - r"""be smaller than everything. """ + r"""be smaller than everything.""" return -1 def __lt__(self, other): return True def __bool__(self): - r"""handles truth testing. """ + r"""handles truth testing.""" return False __nonzero__ = __bool__ # Python2 compatibility diff --git a/armi/reactor/composites.py b/armi/reactor/composites.py index f7d2551cc..290aa342b 100644 --- a/armi/reactor/composites.py +++ b/armi/reactor/composites.py @@ -1679,7 +1679,7 @@ def setChildrenLumpedFissionProducts(self, lfpCollection): c.setLumpedFissionProducts(lfpCollection) def getFissileMassEnrich(self): - r"""returns the fissile mass enrichment. """ + r"""returns the fissile mass enrichment.""" hm = self.getHMMass() if hm > 0: return self.getFissileMass() / hm @@ -1980,7 +1980,7 @@ def getChildParamValues(self, param): return numpy.array([child.p[param] for child in self]) def isFuel(self): - """True if this is a fuel block. """ + """True if this is a fuel block.""" return self.hasFlags(Flags.FUEL) def containsHeavyMetal(self): @@ -2113,7 +2113,7 @@ def getFPMass(self): return mass def getFuelMass(self): - """returns mass of fuel in grams. """ + """returns mass of fuel in grams.""" return sum([fuel.getMass() for fuel in self.iterComponents(Flags.FUEL)], 0.0) def constituentReport(self): diff --git a/armi/reactor/grids.py b/armi/reactor/grids.py index 6b9388e9a..2cac633c4 100644 --- a/armi/reactor/grids.py +++ b/armi/reactor/grids.py @@ -1570,7 +1570,7 @@ def locatorInDomain(self, locator, symmetryOverlap: Optional[bool] = False): return True def isInFirstThird(self, locator, includeTopEdge=False): - """True if locator is in first third of hex grid. """ + """True if locator is in first third of hex grid.""" ring, pos = self.getRingPos(locator.indices) if ring == 1: return True diff --git a/armi/reactor/reactors.py b/armi/reactor/reactors.py index c20a213d6..af734d6a0 100644 --- a/armi/reactor/reactors.py +++ b/armi/reactor/reactors.py @@ -79,7 +79,7 @@ def __init__(self, name, blueprints): self.blueprints = blueprints def __getstate__(self): - r"""applies a settings and parent to the reactor and components. """ + r"""applies a settings and parent to the reactor and components.""" state = composites.Composite.__getstate__(self) state["o"] = None return state @@ -229,7 +229,7 @@ def setOptionsFromCs(self, cs): self._minMeshSizeRatio = cs["minMeshSizeRatio"] def __getstate__(self): - """Applies a settings and parent to the core and components. """ + """Applies a settings and parent to the core and components.""" state = composites.Composite.__getstate__(self) return state @@ -1799,7 +1799,7 @@ def buildZones(self, cs): self.zones = zones.splitZones(self, cs, self.zones) def getCoreRadius(self): - """Returns a radius that the core would fit into. """ + """Returns a radius that the core would fit into.""" return self.getNumRings(indexBased=True) * self.getFirstBlock().getPitch() def findAllMeshPoints(self, assems=None, applySubMesh=True): diff --git a/armi/runLog.py b/armi/runLog.py index ddbce3e67..9fd7f252b 100644 --- a/armi/runLog.py +++ b/armi/runLog.py @@ -383,7 +383,7 @@ def flush(): def prompt(statement, question, *options): - """"Prompt the user for some information.""" + """ "Prompt the user for some information.""" from armi.localization import exceptions if context.CURRENT_MODE == Mode.GUI: diff --git a/armi/tests/armiRun.yaml b/armi/tests/armiRun.yaml index 530bdb162..7418ae195 100644 --- a/armi/tests/armiRun.yaml +++ b/armi/tests/armiRun.yaml @@ -19,11 +19,6 @@ settings: numInternalRings: 1 numExternalRings: 1 XA: - geometry: 0D - externalDriver: true - useHomogenizedBlockComposition: false - numInternalRings: 1 - numExternalRings: 1 fileLocation: - ISOXA cycleLength: 2000.0