diff --git a/docs/source/methods/depletion.rst b/docs/source/methods/depletion.rst index dce1f2503..1b131bed5 100644 --- a/docs/source/methods/depletion.rst +++ b/docs/source/methods/depletion.rst @@ -103,16 +103,17 @@ integrate over the entire timestep. Our aim here is not to exhaustively describe all integration methods but rather to give a few examples that elucidate the main considerations one must take into account when choosing a method. Generally, there is a tradeoff between the -accuracy of the method and its computational expense. The expense is driven -almost entirely by the time to compute a transport solution, i.e., to evaluate -:math:`\mathbf{A}` for a given :math:`\mathbf{n}`. Thus, the cost of a method -scales with the number of :math:`\mathbf{A}` evaluations that are performed per -timestep. On the other hand, methods that require more evaluations generally -achieve higher accuracy. The predictor method only requires one evaluation and -its error converges as :math:`\mathcal{O}(h)`. The CE/CM method requires two -evaluations and is thus twice as expensive as the predictor method, but achieves -an error of :math:`\mathcal{O}(h^2)`. An exhaustive description of time -integration methods and their merits can be found in the `thesis of Colin Josey +accuracy of the method and its computational expense. In the case of +transport-coupled depletion, the expense is driven almost entirely by the time +to compute a transport solution, i.e., to evaluate :math:`\mathbf{A}` for a +given :math:`\mathbf{n}`. Thus, the cost of a method scales with the number of +:math:`\mathbf{A}` evaluations that are performed per timestep. On the other +hand, methods that require more evaluations generally achieve higher accuracy. +The predictor method only requires one evaluation and its error converges as +:math:`\mathcal{O}(h)`. The CE/CM method requires two evaluations and is thus +twice as expensive as the predictor method, but achieves an error of +:math:`\mathcal{O}(h^2)`. An exhaustive description of time integration methods +and their merits can be found in the `thesis of Colin Josey `_. OpenMC does not rely on a single time integration method but rather has several @@ -169,12 +170,14 @@ Data Considerations In principle, solving Eq. :eq:`depletion-matrix` using CRAM is fairly simple: just construct the burnup matrix at various times and solve a set of sparse -linear systems. However, constructing the burnup matrix itself involves not only -solving the transport equation to estimate transmutation reaction rates but also -a series of choices about what data to include. In OpenMC, the burnup matrix is -constructed based on data inside of a *depletion chain* file, which includes -fundamental data gathered from ENDF incident neutron, decay, and fission product -yield sublibraries. For each nuclide, this file includes: +linear systems. However, constructing the burnup matrix itself involves not +only solving the transport equation to estimate transmutation reaction rates +(in the case of transport-coupled depletion) or to obtain microscopic cross +sections (in the case of transport-independent depletion), but also a series of +choices about what data to include. In OpenMC, the burnup matrix is constructed +based on data inside of a *depletion chain* file, which includes fundamental +data gathered from ENDF incident neutron, decay, and fission product yield +sublibraries. For each nuclide, this file includes: - What transmutation reactions are possible, their Q values, and their products; - If a nuclide is not stable, what decay modes are possible, their branching @@ -185,9 +188,12 @@ yield sublibraries. For each nuclide, this file includes: Transmutation Reactions ----------------------- -OpenMC will setup tallies in a problem based on what transmutation reactions are -available in a depletion chain file, so any arbitrary number of transmutation -reactions can be tracked. The pregenerated chain files that are available on +In transport-coupled depletion, OpenMC will setup tallies in a problem based on +what transmutation reactions are available in a depletion chain file, so any +arbitrary number of transmutation reactions can be tracked. In +transport-independent depletion, OpenMC will calculate reaction rates for every +reaction that is present in both the available cross sections and the depletion +chain file. The pregenerated chain files that are available on https://openmc.org include the following transmutation reactions: fission, (n,\ :math:`\gamma`\ ), (n,2n), (n,3n), (n,4n), (n,p), and (n,\ :math:`\alpha`\ ). @@ -202,11 +208,12 @@ accurately model the branching of the capture reaction in Am241. This is complicated by the fact that the branching ratio may depend on the incident neutron energy causing capture. -OpenMC does not currently allow energy-dependent capture branching ratios. -However, the depletion chain file does allow a transmutation reaction to be -listed multiple times with different branching ratios resulting in different -products. Spectrum-averaged capture branching ratios have been computed in LWR -and SFR spectra and are available at https://openmc.org/depletion-chains. +OpenMC's transport solver does not currently allow energy-dependent capture +branching ratios. However, the depletion chain file does allow a transmutation +reaction to be listed multiple times with different branching ratios resulting +in different products. Spectrum-averaged capture branching ratios have been +computed in LWR and SFR spectra and are available at +https://openmc.org/depletion-chains. Fission Product Yields ---------------------- @@ -217,26 +224,31 @@ energies. It is an open question as to what the best way to handle this energy dependence is. OpenMC includes three methods for treating the energy dependence of FPY: -1. Use FPY data corresponding to a specified energy. +1. Use FPY data corresponding to a specified energy. This is used by default in + both transport-coupled and transport-independent depletion. 2. Tally fission rates above and below a specified cutoff energy. Assume that all fissions below the cutoff energy correspond to thermal FPY data and all - fission above the cutoff energy correspond to fast FPY data. + fission above the cutoff energy correspond to fast FPY data. Only applicable + to transport-coupled depletion. 3. Compute the average energy at which fission events occur and use an effective FPY by linearly interpolating between FPY provided at neighboring energies. + Only applicable to transport-coupled depletion. -The method can be selected through the ``fission_yield_mode`` argument to the -:class:`openmc.deplete.Operator` constructor. +The method for transport-coupled depletion can be selected through the +``fission_yield_mode`` argument to the :class:`openmc.deplete.CoupledOperator` +constructor. Power Normalization ------------------- -The reaction rates provided OpenMC are given in units of reactions per source -particle. For depletion, it is necessary to compute an absolute reaction rate in -reactions per second. To do so, the reaction rates are normalized based on a -specified power. A complete description of how this normalization can be -performed is described in :ref:`usersguide_tally_normalization`. Here, we simply -note that the main depletion class, :class:`openmc.deplete.Operator`, allows the -user to choose one of two methods for estimating the heating rate, including: +In transport-coupled depletion, the reaction rates provided OpenMC are given in +units of reactions per source particle. For depletion, it is necessary to +compute an absolute reaction rate in reactions per second. To do so, the +reaction rates are normalized based on a specified power. A complete +description of how this normalization can be performed is described in +:ref:`usersguide_tally_normalization`. Here, we simply note that the main +depletion class, :class:`openmc.deplete.CoupledOperator`, allows the user to +choose one of two methods for estimating the heating rate, including: 1. Using fixed Q values from a depletion chain file (useful for comparisons to other codes that use fixed Q values), or @@ -244,4 +256,4 @@ user to choose one of two methods for estimating the heating rate, including: energy-dependent estimate of the true heating rate. The method for normalization can be chosen through the ``normalization_mode`` -argument to the :class:`openmc.deplete.Operator` class. +argument to the :class:`openmc.deplete.CoupledOperator` class. diff --git a/docs/source/pythonapi/deplete.rst b/docs/source/pythonapi/deplete.rst index 9f7d8c447..0b7faceb7 100644 --- a/docs/source/pythonapi/deplete.rst +++ b/docs/source/pythonapi/deplete.rst @@ -15,16 +15,18 @@ are: 1) A transport operator 2) A time-integration scheme -The former is responsible for executing a transport code, like OpenMC, -and retaining important information required for depletion. The most common examples -are reaction rates and power normalization data. The latter is responsible for -projecting reaction rates and compositions forward in calendar time across -some step size :math:`\Delta t`, and obtaining new compositions given a power -or power density. The :class:`Operator` is provided to handle communicating with -OpenMC. Several classes are provided that implement different time-integration -algorithms for depletion calculations, which are described in detail in Colin -Josey's thesis, `Development and analysis of high order neutron -transport-depletion coupling algorithms `_. +The former is responsible for calculating and retaining important information +required for depletion. The most common examples are reaction rates and power +normalization data. The latter is responsible for projecting reaction rates and +compositions forward in calendar time across some step size :math:`\Delta t`, +and obtaining new compositions given a power or power density. The +:class:`CoupledOperator` class is provided to obtain reaction rates via tallies +through OpenMC's transport solver, and the :class:`IndependentOperator` class is +provided to obtain reaction rates from cross-section data. Several classes are +provided that implement different time-integration algorithms for depletion +calculations, which are described in detail in Colin Josey's thesis, +`Development and analysis of high order neutron transport-depletion coupling +algorithms `_. .. autosummary:: :toctree: generated @@ -40,18 +42,20 @@ transport-depletion coupling algorithms `_. SICELIIntegrator SILEQIIntegrator -Each of these classes expects a "transport operator" to be passed. An operator -specific to OpenMC is available using the following class: +Each of these classes expects a "transport operator" to be passed. OpenMC +provides The following classes implementing transpor operators: .. autosummary:: :toctree: generated :nosignatures: :template: mycallable.rst - Operator + CoupledOperator + IndependentOperator -The :class:`Operator` must also have some knowledge of how nuclides transmute -and decay. This is handled by the :class:`Chain`. +The :class:`CoupledOperator` and :class:`IndependentOperator` classes must also +have some knowledge of how nuclides transmute and decay. This is handled by the +:class:`Chain`. Minimal Example --------------- @@ -64,11 +68,12 @@ A minimal example for performing depletion would be: >>> import openmc.deplete >>> geometry = openmc.Geometry.from_xml() >>> settings = openmc.Settings.from_xml() + >>> model = openmc.model.Model(geometry, settings) # Representation of a depletion chain >>> chain_file = "chain_casl.xml" - >>> operator = openmc.deplete.Operator( - ... geometry, settings, chain_file) + >>> operator = openmc.deplete.CoupledOperator( + ... model, chain_file) # Set up 5 time steps of one day each >>> dt = [24 * 60 * 60] * 5 @@ -131,6 +136,7 @@ data, such as number densities and reaction rates for each material. :template: myclass.rst AtomNumber + MicroXS OperatorResult ReactionRates Results @@ -172,7 +178,7 @@ with :func:`cram.CRAM48` being the default. :class:`multiprocessing.pool.Pool` class. If set to ``None`` (default), the number returned by :func:`os.cpu_count` is used. -The following classes are used to help the :class:`openmc.deplete.Operator` +The following classes are used to help the :class:`openmc.deplete.CoupledOperator` compute quantities like effective fission yields, reaction rates, and total system energy. @@ -189,14 +195,45 @@ total system energy. helpers.FissionYieldCutoffHelper helpers.FluxCollapseHelper +The :class:`openmc.deplete.IndependentOperator` uses inner classes subclassed +from those listed above to perform similar calculations. + +Intermediate Classes +-------------------- + +Specific implementations of abstract base classes may utilize some of +the same methods and data structures. These methods and data are stored +in intermediate classes. + +Methods common to tally-based implementation of :class:`FissionYieldHelper` +are stored in :class:`helpers.TalliedFissionYieldHelper` + +.. autosummary:: + :toctree: generated + :nosignatures: + :template: myclass.rst + + helpers.TalliedFissionYieldHelper + +Methods common to OpenMC-specific implementations of :class:`TransportOperator` +are stored in :class:`openmc_operator.OpenMCOperator` + +.. autosummary:: + :toctree: generated + :nosignatures: + :template: mycallable.rst + + openmc_operator.OpenMCOperator + + Abstract Base Classes --------------------- A good starting point for extending capabilities in :mod:`openmc.deplete` is to examine the following abstract base classes. Custom classes can inherit from :class:`abc.TransportOperator` to implement alternative -schemes for collecting reaction rates and other data from a transport code -prior to depleting materials +schemes for collecting reaction rates and other data prior to depleting +materials .. autosummary:: :toctree: generated @@ -206,7 +243,9 @@ prior to depleting materials abc.TransportOperator The following classes are abstract classes used to pass information from -OpenMC simulations back on to the :class:`abc.TransportOperator` +transport simulations (in the case of transport-coupled depletion) or to +simply calculate these quantities directly (in the case of +transport-independent depletion) back on to the :class:`abc.TransportOperator` .. autosummary:: :toctree: generated @@ -216,7 +255,6 @@ OpenMC simulations back on to the :class:`abc.TransportOperator` abc.NormalizationHelper abc.FissionYieldHelper abc.ReactionRateHelper - abc.TalliedFissionYieldHelper Custom integrators or depletion solvers can be developed by subclassing from the following abstract base classes: diff --git a/docs/source/usersguide/depletion.rst b/docs/source/usersguide/depletion.rst index 57619611f..29c8114d9 100644 --- a/docs/source/usersguide/depletion.rst +++ b/docs/source/usersguide/depletion.rst @@ -4,50 +4,30 @@ Depletion and Transmutation =========================== -OpenMC supports coupled depletion, or burnup, calculations through the -:mod:`openmc.deplete` Python module. OpenMC solves the transport equation to -obtain transmutation reaction rates, and then the reaction rates are used to -solve a set of transmutation equations that determine the evolution of nuclide -densities within a material. The nuclide densities predicted as some future time -are then used to determine updated reaction rates, and the process is repeated -for as many timesteps as are requested. +OpenMC supports transport-coupled and transport-independent depletion, or +burnup, calculations through the :mod:`openmc.deplete` Python module. OpenMC +uses transmutation reaction rates to solve a set of transmutation equations +that determine the evolution of nuclide densities within a material. The +nuclide densities predicted at some future time are then used to determine +updated reaction rates, and the process is repeated for as many timesteps as +are requested. -The depletion module is designed such that the flux/reaction rate solution (the +The depletion module is designed such that the reaction rate solution (the transport "operator") is completely isolated from the solution of the -transmutation equations and the method used for advancing time. At present, the -:mod:`openmc.deplete` module offers a single transport operator, -:class:`openmc.deplete.Operator` (which uses the OpenMC transport solver), but -in principle additional operator classes based on other transport codes could be -implemented and no changes to the depletion solver itself would be needed. The -operator class requires a :class:`openmc.Geometry` instance and a -:class:`openmc.Settings` instance:: - - geom = openmc.Geometry() - settings = openmc.Settings() - ... - - op = openmc.deplete.Operator(geom, settings) - -Any material that contains a fissionable nuclide is depleted by default, but -this can behavior can be changed with the :attr:`Material.depletable` attribute. - -.. important:: The volume must be specified for each material that is depleted by - setting the :attr:`Material.volume` attribute. This is necessary - in order to calculate the proper normalization of tally results - based on the source rate. +transmutation equations and the method used for advancing time. :mod:`openmc.deplete` supports multiple time-integration methods for determining material compositions over time. Each method appears as a different class. For example, :class:`openmc.deplete.CECMIntegrator` runs a depletion calculation using the CE/CM algorithm (deplete over a timestep using the middle-of-step -reaction rates). An instance of :class:`openmc.deplete.Operator` is passed to -one of these functions along with the timesteps and power level:: +reaction rates). An instance of :class:`~openmc.deplete.abc.TransportOperator` +is passed to one of these functions along with the timesteps and power level:: power = 1200.0e6 # watts timesteps = [10.0, 10.0, 10.0] # days openmc.deplete.CECMIntegrator(op, timesteps, power, timestep_units='d').integrate() -The coupled transport-depletion problem is executed, and once it is done a +The depletion problem is executed, and once it is done a ``depletion_results.h5`` file is written. The results can be analyzed using the :class:`openmc.deplete.Results` class. This class has methods that allow for easy retrieval of k-effective, nuclide concentrations, and reaction rates over @@ -56,11 +36,41 @@ time:: results = openmc.deplete.Results("depletion_results.h5") time, keff = results.get_keff() -Note that the coupling between the transport solver and the transmutation solver -happens in-memory rather than by reading/writing files on disk. +Note that the coupling between the reaction rate solver and the transmutation +solver happens in-memory rather than by reading/writing files on disk. OpenMC +has two categories of transport operators for obtaining transmutation reaction +rates. + +.. _coupled-depletion: + +Transport-coupled depletion +=========================== + +This category of operator solves the transport equation to obtain transmutation +reaction rates. At present, the :mod:`openmc.deplete` module offers a single +transport-coupled operator, :class:`openmc.deplete.CoupledOperator` (which uses +the OpenMC transport solver), but in principle additional transport-coupled +operator classes based on other transport codes could be implemented and no +changes to the depletion solver itself would be needed. The +:class:`openmc.deplete.CoupledOperator` class requires a :class:`~openmc.Model` +instance containing material, geometry, and settings information:: + + model = openmc.Model() + ... + + op = openmc.deplete.CoupledOperator(model) + +Any material that contains a fissionable nuclide is depleted by default, but +this can behavior can be changed with the :attr:`Material.depletable` attribute. + +.. important:: + + The volume must be specified for each material that is depleted by setting + the :attr:`Material.volume` attribute. This is necessary in order to + calculate the proper normalization of tally results based on the source rate. Fixed-Source Transmutation -========================== +-------------------------- When the ``power`` or ``power_density`` argument is used for one of the Integrator classes, it is assumed that OpenMC is running in k-eigenvalue mode, @@ -77,11 +87,11 @@ using the :attr:`Material.depletable` attribute:: mat = openmc.Material() mat.depletable = True -When constructing the :class:`~openmc.deplete.Operator`, you should indicate -that normalization of tally results will be done based on the source rate rather -than a power or power density:: +When constructing the :class:`~openmc.deplete.CoupledOperator`, you should +indicate that normalization of tally results will be done based on the source +rate rather than a power or power density:: - op = openmc.deplete.Operator(geometry, settings, normalization_mode='source-rate') + op = openmc.deplete.CoupledOperator(model, normalization_mode='source-rate') Finally, when creating a depletion integrator, use the ``source_rates`` argument:: @@ -92,19 +102,22 @@ timestep in the calculation. A zero source rate for a given timestep will result in a decay-only step, where all reaction rates are zero. Caveats -======= +------- + +.. _energy-deposition: Energy Deposition ------------------ +~~~~~~~~~~~~~~~~~ The default energy deposition mode, ``"fission-q"``, instructs the -:class:`openmc.deplete.Operator` to normalize reaction rates using the product -of fission reaction rates and fission Q values taken from the depletion chain. -This approach does not consider indirect contributions to energy deposition, -such as neutron heating and energy from secondary photons. In doing this, the -energy deposited during a transport calculation will be lower than expected. -This causes the reaction rates to be over-adjusted to hit the user-specific -power, or power density, leading to an over-depletion of burnable materials. +:class:`~openmc.deplete.CoupledOperator` to normalize reaction rates using the +product of fission reaction rates and fission Q values taken from the depletion +chain. This approach does not consider indirect contributions to energy +deposition, such as neutron heating and energy from secondary photons. In doing +this, the energy deposited during a transport calculation will be lower than +expected. This causes the reaction rates to be over-adjusted to hit the +user-specific power, or power density, leading to an over-depletion of burnable +materials. There are some remedies. First, the fission Q values can be directly set in a variety of ways. This requires knowing what the total fission energy release @@ -113,29 +126,32 @@ should be, including indirect components. Some examples are provided below:: # use a dictionary of fission_q values fission_q = {"U235": 202e+6} # energy in eV + # create a Model object + model = openmc.Model(geometry, settings) + # create a modified chain and write it to a new file chain = openmc.deplete.Chain.from_xml("chain.xml", fission_q) chain.export_to_xml("chain_mod_q.xml") - op = openmc.deplete.Operator(geometry, setting, "chain_mod_q.xml") + op = openmc.deplete.CoupledOperator(model, "chain_mod_q.xml") # alternatively, pass the modified fission Q directly to the operator - op = openmc.deplete.Operator(geometry, setting, "chain.xml", + op = openmc.deplete.CoupledOperator(model, "chain.xml", fission_q=fission_q) A more complete way to model the energy deposition is to use the modified -heating reactions described in :ref:`methods_heating`. These values can be used +heating reactions described in :ref:`methods_heating`. These values can be used to normalize reaction rates instead of using the fission reaction rates with:: - op = openmc.deplete.Operator(geometry, settings, "chain.xml", + op = openmc.deplete.CoupledOperator(model, "chain.xml", normalization_mode="energy-deposition") These modified heating libraries can be generated by running the latest version -of :meth:`openmc.data.IncidentNeutron.from_njoy`, and will eventually be bundled +of :meth:`openmc.data.IncidentNeutron.from_njoy()`, and will eventually be bundled into the distributed libraries. Local Spectra and Repeated Materials ------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ It is not uncommon to explicitly create a single burnable material across many locations. From a pure transport perspective, there is nothing wrong with @@ -160,7 +176,7 @@ the next transport step. This can be countered by instructing the operator to treat repeated instances of the same material as a unique material definition with:: - op = openmc.deplete.Operator(geometry, settings, chain_file, + op = openmc.deplete.CoupledOperator(model, chain_file, diff_burnable_mats=True) For our example problem, this would deplete fuel on the outer region of the @@ -177,3 +193,164 @@ across all material instances. This will increase the total memory usage and run time due to an increased number of tallies and material definitions. +Transport-independent depletion +=============================== + +.. warning:: + + This feature is still under heavy development and has yet to be rigorously + verified. API changes and feature additions are possible and likely in + the near future. + +This category of operator uses pre-calculated one-group microscopic cross +sections to obtain transmutation reaction rates. OpenMC provides the +:class:`~openmc.deplete.IndependentOperator` for this method of calculation. +While the one-group microscopic cross sections can be calculated using a +transport solver, :class:`~openmc.deplete.IndependentOperator` is not directly +coupled to any transport solver. The +:class:`~openmc.deplete.IndependentOperator` class requires a +:class:`openmc.Materials` object, a :class:`~openmc.deplete.MicroXS` object, +and a path to a depletion chain file:: + + # load in the microscopic cross sections + materials = openmc.Materials() + ... + + micro_xs = openmc.deplete.MicroXS.from_csv(micro_xs_path) + op = openmc.deplete.IndependentOperator(materials, micro_xs, chain_file) + +.. note:: + + The same statements from :ref:`coupled-depletion` about which + materials are depleted and the requirement for depletable materials to have + a specified volume also apply here. + +An alternate constructor, +:meth:`~openmc.deplete.IndependentOperator.from_nuclides`, accepts a volume and +dictionary of nuclide concentrations in place of the :class:`openmc.Materials` +object:: + + nuclides = {'U234': 8.92e18, + 'U235': 9.98e20, + 'U238': 2.22e22, + 'U236': 4.57e18, + 'O16': 4.64e22, + 'O17': 1.76e19} + volume = 0.5 + op = openmc.deplete.IndependentOperator.from_nuclides(volume, + nuclides, + micro_xs, + chain_file, + nuc_units='atom/cm3') + +A user can then define an integrator class as they would for a coupled +transport-depletion calculation and follow the same steps from there. + +.. note:: + + Ideally, one-group cross section data should be available for every + reaction in the depletion chain. If a nuclide that has a reaction + associated with it in the depletion chain is present in the `nuclides` + parameter but not the cross section data, that reaction will not be + simulated. + +Generating Microscopic Cross Sections +------------------------------------- + +Users can generate the one-group microscopic cross sections needed by +:class:`~openmc.deplete.IndependentOperator` using the +:class:`~openmc.deplete.MicroXS` class:: + + import openmc + + model = openmc.Model.from_xml() + + micro_xs = openmc.deplete.MicroXS.from_model(model, + model.materials[0], + chain_file) + +The :meth:`~openmc.deplete.MicroXS.from_model()` method will produce a +:class:`~openmc.deplete.MicroXS` object with microscopic cross section data in +units of barns, which is what :class:`~openmc.deplete.IndependentOperator` +expects the units to be. The :class:`~openmc.deplete.MicroXS` class also +includes functions to read in cross section data directly from a ``.csv`` file +or from data arrays:: + + micro_xs = MicroXS.from_csv(micro_xs_path) + + nuclides = ['U234', 'U235', 'U238'] + reactions = ['fission', '(n,gamma)'] + data = np.array([[0.1, 0.2], + [0.3, 0.4], + [0.01, 0.5]]) + micro_xs = MicroXS.from_array(nuclides, reactions, data) + +.. important:: + + Both :meth:`~openmc.deplete.MicroXS.from_csv()` and + :meth:`~openmc.deplete.MicroXS.from_array()` assume the cross section values + provided are in barns by defualt, but have no way of verifying this. Make + sure your cross sections are in the correct units before passing to a + :class:`~openmc.deplete.IndependentOperator` object. + +Caveats +------- + +Reaction Rate Normalization +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :class:`~openmc.deplete.IndependentOperator` class supports two methods for +normalizing reaction rates: + +.. important:: + + Make sure you set the correct parameter in the :class:`openmc.abc.Integrator` + class. Use the ``source_rates`` parameter when + ``normalization_mode == source-rate``, and use ``power`` or ``power_density`` + when ``normalization_mode == fission-q``. + +1. ``source-rate`` normalization, which assumes the ``source_rate`` provided by + the time integrator is a flux, and obtains the reaction rates by multiplying + the cross-sections by the ``source-rate``. +2. ``fission-q`` normalization, which uses the ``power`` or ``power_density`` + provided by the time integrator to obtain reaction rates by computing a value + for the flux based on this power. The general equation for the flux is + + .. math:: + + \phi = \frac{P}{V \cdot \sum_i (Q_i \cdot \Sigma^f_i \cdot \rho_i)} + + where :math:`\sum_i` is the sum over all nuclides :math:`i`. This equation + makes the same assumptions and issues as discussed in + :ref:`energy-deposition`. Unfortunately, the proposed solution in that + section does not apply here since we are decoupled from transport code. + However, there is a method to converge to a more accurate value for flux by + using substeps during time integration. + `This paper `_ provides a + good discussion of this method. + +.. warning:: + + The accuracy of results when using ``fission-q`` is entirely dependent on + your depletion chain. Make sure it has sufficient data to resolve the + dynamics of your particular scenario. + +Multiple Materials +~~~~~~~~~~~~~~~~~~ + +Running a depletion simulation with multiple materials using the +``source-rate`` normalization method treats each material as completely +separate with respect to reaction rates. This can be useful for running many +different cases of a particular scenario. However, running a depletion +simulation with multiple materials using the ``fission-q`` normalization method +treats each material as part of the same "reactor" due to how ``fission-q`` +normalization accumulates energy values from each material to a single value. +This behavior may change in the future. + +Time integration +~~~~~~~~~~~~~~~~ + +The one-group microscopic cross sections passed to +:class:`openmc.deplete.IndependentOperator` are fixed values for the entire +depletion simulation. This implicit assumption may produce inaccurate results +for certain scenarios. diff --git a/openmc/deplete/__init__.py b/openmc/deplete/__init__.py index 5891555a2..329ee8b52 100644 --- a/openmc/deplete/__init__.py +++ b/openmc/deplete/__init__.py @@ -7,7 +7,10 @@ A depletion front-end tool. from .nuclide import * from .chain import * -from .operator import * +from .openmc_operator import * +from .coupled_operator import * +from .independent_operator import * +from .microxs import * from .reaction_rates import * from .atom_number import * from .stepresult import * diff --git a/openmc/deplete/abc.py b/openmc/deplete/abc.py index 8bbe126df..e3587f052 100644 --- a/openmc/deplete/abc.py +++ b/openmc/deplete/abc.py @@ -1,7 +1,6 @@ -"""function module. +"""abc module. -This module contains the Operator class, which is then passed to an integrator -to run a full depletion simulation. +This module contains Abstract Base Classes for implementing operator, integrator, depletion system solver, and operator helper classes """ from abc import ABC, abstractmethod @@ -29,8 +28,8 @@ from .pool import deplete __all__ = [ - "OperatorResult", "TransportOperator", "ReactionRateHelper", - "NormalizationHelper", "FissionYieldHelper", + "OperatorResult", "TransportOperator", + "ReactionRateHelper", "NormalizationHelper", "FissionYieldHelper", "Integrator", "SIIntegrator", "DepSystemSolver", "add_params"] @@ -83,7 +82,8 @@ class TransportOperator(ABC): operator that takes a vector of material compositions and returns an eigenvalue and reaction rates. This abstract class sets the requirements for such a transport operator. Users should instantiate - :class:`openmc.deplete.Operator` rather than this class. + :class:`openmc.deplete.CoupledOperator` or + :class:`openmc.deplete.IndependentOperator` rather than this class. Parameters ---------- @@ -221,9 +221,11 @@ class ReactionRateHelper(ABC): Parameters ---------- n_nucs : int - Number of burnable nuclides tracked by :class:`openmc.deplete.Operator` + Number of burnable nuclides tracked by + :class:`openmc.deplete.abc.TransportOperator` n_react : int - Number of reactions tracked by :class:`openmc.deplete.Operator` + Number of reactions tracked by + :class:`openmc.deplete.abc.TransportOperator` Attributes ---------- @@ -292,9 +294,9 @@ class NormalizationHelper(ABC): """Abstract class for obtaining normalization factor on tallies This helper class determines how reaction rates calculated by an instance of - :class:`openmc.deplete.Operator` should be normalized for the purpose of - constructing a burnup matrix. Based on the method chosen, the power or - source rate provided by the user, and reaction rates from a + :class:`openmc.deplete.abc.TransportOperator` should be normalized for the + purpose of constructing a burnup matrix. Based on the method chosen, the + power or source rate provided by the user, and reaction rates from a :class:`ReactionRateHelper`, this class will scale reaction rates to the correct values. @@ -302,7 +304,7 @@ class NormalizationHelper(ABC): ---------- nuclides : list of str All nuclides with desired reaction rates. Ordered to be - consistent with :class:`openmc.deplete.Operator` + consistent with :class:`openmc.deplete.abc.TransportOperator` """ @@ -316,9 +318,9 @@ class NormalizationHelper(ABC): def prepare(self, chain_nucs, rate_index): """Perform work needed to obtain energy produced - This method is called prior to the transport simulations - in :meth:`openmc.deplete.Operator.initial_condition`. Only used for - energy-based normalization. + This method is called prior to calculating the reaction rates + in :meth:`openmc.deplete.abc.TransportOperator.initial_condition`. Only + used for energy-based normalization. Parameters ---------- @@ -329,7 +331,7 @@ class NormalizationHelper(ABC): `fission_rates` for :meth:`update`. """ - def update(self, fission_rates): + def update(self, fission_rates, mat_index=None): """Update the normalization based on fission rates (only used for energy-based normalization) @@ -339,6 +341,8 @@ class NormalizationHelper(ABC): fission reaction rate for each isotope in the specified material. Should be ordered corresponding to initial ``rate_index`` used in :meth:`prepare` + mat_index : int + Material index """ @property @@ -429,7 +433,7 @@ class FissionYieldHelper(ABC): def unpack(): """Unpack tally data prior to compute fission yields. - Called after a :meth:`openmc.deplete.Operator.__call__` + Called after a :meth:`openmc.deplete.abc.TransportOperator.__call__` routine during the normalization of reaction rates. Not necessary for all subclasses to implement, unless tallies @@ -450,7 +454,7 @@ class FissionYieldHelper(ABC): mat_indexes : iterable of int Indices of tallied materials that will have their fission yields computed by this helper. Necessary as the - :class:`openmc.deplete.Operator` that uses this helper + :class:`openmc.deplete.CoupledOperator` that uses this helper may only burn a subset of all materials when running in parallel mode. """ @@ -462,14 +466,15 @@ class FissionYieldHelper(ABC): ---------- nuclides : iterable of str Nuclides with non-zero densities from the - :class:`openmc.deplete.Operator` + :class:`openmc.deplete.abc.TransportOperator` Returns ------- nuclides : list of str - Union of nuclides that the :class:`openmc.deplete.Operator` - says have non-zero densities at this stage and those that - have yield data. Sorted by nuclide name + Union of nuclides that the + :class:`openmc.deplete.abc.TransportOperator` says have non-zero + densities at this stage and those that have yield data. Sorted by + nuclide name """ return sorted(self._chain_set & set(nuclides)) @@ -483,7 +488,7 @@ class FissionYieldHelper(ABC): Parameters ---------- - operator : openmc.deplete.TransportOperator + operator : openmc.deplete.abc.TransportOperator Operator with a depletion chain kwargs: optional Additional keyword arguments to be used in constuction @@ -504,7 +509,7 @@ class Integrator(ABC): _params = r""" Parameters ---------- - operator : openmc.deplete.TransportOperator + operator : openmc.deplete.abc.TransportOperator Operator to perform transport simulations timesteps : iterable of float or iterable of tuple Array of timesteps. Note that values are not cumulative. The units are @@ -522,9 +527,10 @@ class Integrator(ABC): power_density : float or iterable of float, optional Power density of the reactor in [W/gHM]. It is multiplied by initial heavy metal inventory to get total power if ``power`` - is not speficied. + is not specified. source_rates : float or iterable of float, optional - Source rate in [neutron/sec] for each interval in :attr:`timesteps` + Source rate in [neutron/sec] or neutron flux in [neutron/s-cm^2] for + each interval in :attr:`timesteps` .. versionadded:: 0.12.1 timestep_units : {'s', 'min', 'h', 'd', 'MWd/kg'} @@ -546,7 +552,7 @@ class Integrator(ABC): Attributes ---------- - operator : openmc.deplete.TransportOperator + operator : openmc.deplete.abc.TransportOperator Operator to perform transport simulations chain : openmc.deplete.Chain Depletion chain @@ -841,8 +847,8 @@ class SIIntegrator(Integrator): _params = r""" Parameters ---------- - operator : openmc.deplete.TransportOperator - The operator object to simulate on. + operator : openmc.deplete.abc.TransportOperator + Operator to perform transport simulations timesteps : iterable of float or iterable of tuple Array of timesteps. Note that values are not cumulative. The units are specified by the `timestep_units` argument when `timesteps` is an @@ -859,9 +865,10 @@ class SIIntegrator(Integrator): power_density : float or iterable of float, optional Power density of the reactor in [W/gHM]. It is multiplied by initial heavy metal inventory to get total power if ``power`` - is not speficied. + is not specified. source_rates : float or iterable of float, optional - Source rate in [neutron/sec] for each interval in :attr:`timesteps` + Source rate in [neutron/sec] or neutron flux in [neutron/s-cm^2] for + each interval in :attr:`timesteps` .. versionadded:: 0.12.1 timestep_units : {'s', 'min', 'h', 'd', 'MWd/kg'} @@ -886,7 +893,7 @@ class SIIntegrator(Integrator): Attributes ---------- - operator : openmc.deplete.TransportOperator + operator : openmc.deplete.abc.TransportOperator Operator to perform transport simulations chain : openmc.deplete.Chain Depletion chain @@ -1006,7 +1013,7 @@ class DepSystemSolver(ABC): Parameters ---------- A : scipy.sparse.csr_matrix - Sparse transmutation matrix ``A[j, i]`` desribing rates at + Sparse transmutation matrix ``A[j, i]`` describing rates at which isotope ``i`` transmutes to isotope ``j`` n0 : numpy.ndarray Initial compositions, typically given in number of atoms in some diff --git a/openmc/deplete/atom_number.py b/openmc/deplete/atom_number.py index 5430ddc35..78ceecca6 100644 --- a/openmc/deplete/atom_number.py +++ b/openmc/deplete/atom_number.py @@ -126,6 +126,23 @@ class AtomNumber: return [nuc for nuc, ind in self.index_nuc.items() if ind < self.n_nuc_burn] + def get_mat_volume(self, mat): + """Return material volume + + Parameters + ---------- + mat : str, int, openmc.Material, or slice + Material index. + + Returns + ------- + float + Material volume in [cm^3] + + """ + mat = self._get_mat_index(mat) + return self.volume[mat] + def get_atom_density(self, mat, nuc): """Return atom density of given material and nuclide diff --git a/openmc/deplete/chain.py b/openmc/deplete/chain.py index 0c1c34cf7..d63bc6bfb 100644 --- a/openmc/deplete/chain.py +++ b/openmc/deplete/chain.py @@ -264,7 +264,8 @@ class Chain: requires a list of ENDF incident neutron, decay, and neutron fission product yield sublibrary files. The depletion chain used during a depletion simulation is indicated by either an argument to - :class:`openmc.deplete.Operator` or through the + :class:`openmc.deplete.CoupledOperator` or + :class:`openmc.deplete.IndependentOperator`, or through the ``depletion_chain`` item in the :envvar:`OPENMC_CROSS_SECTIONS` environment variable. diff --git a/openmc/deplete/coupled_operator.py b/openmc/deplete/coupled_operator.py new file mode 100644 index 000000000..eba90f60d --- /dev/null +++ b/openmc/deplete/coupled_operator.py @@ -0,0 +1,546 @@ +"""Transport-coupled transport operator for depletion. + +This module implements a transport operator coupled to OpenMC's transport solver +so that it can be used by depletion integrators. The implementation makes use of +the Python bindings to OpenMC's C API so that reading tally results and updating +material number densities is all done in-memory instead of through the +filesystem. + +""" + +import copy +import os +from warnings import warn + +import numpy as np +from uncertainties import ufloat + +import openmc +from openmc.checkvalue import check_value +from openmc.data import DataLibrary +from openmc.exceptions import DataError +import openmc.lib +from openmc.mpi import comm +from .abc import OperatorResult +from .chain import _find_chain_file +from .openmc_operator import OpenMCOperator, _distribute +from .results import Results +from .helpers import ( + DirectReactionRateHelper, ChainFissionHelper, ConstantFissionYieldHelper, + FissionYieldCutoffHelper, AveragedFissionYieldHelper, EnergyScoreHelper, + SourceRateHelper, FluxCollapseHelper) + + +__all__ = ["CoupledOperator", "Operator", "OperatorResult"] + + +def _find_cross_sections(model): + """Determine cross sections to use for depletion + + Parameters + ---------- + model : openmc.model.Model + Reactor model + + """ + if model.materials and model.materials.cross_sections is not None: + # Prefer info from Model class if available + return model.materials.cross_sections + + # otherwise fallback to environment variable + cross_sections = os.environ.get("OPENMC_CROSS_SECTIONS") + if cross_sections is None: + raise DataError( + "Cross sections were not specified in Model.materials and " + "the OPENMC_CROSS_SECTIONS environment variable is not set." + ) + return cross_sections + +def _get_nuclides_with_data(cross_sections): + """Loads cross_sections.xml file to find nuclides with neutron data + + Parameters + ---------- + cross_sections : str + Path to cross_sections.xml file + + Returns + ------- + nuclides : set of str + Set of nuclide names that have cross secton data + + """ + nuclides = set() + data_lib = DataLibrary.from_xml(cross_sections) + for library in data_lib.libraries: + if library['type'] != 'neutron': + continue + for name in library['materials']: + if name not in nuclides: + nuclides.add(name) + + return nuclides + +class CoupledOperator(OpenMCOperator): + """Transport-coupled transport operator. + + Instances of this class can be used to perform transport-coupled depletion + using OpenMC's transport solver. Normally, a user needn't call methods of + this class directly. Instead, an instance of this class is passed to an + integrator class, such as :class:`openmc.deplete.CECMIntegrator`. + + .. versionchanged:: 0.13.0 + The geometry and settings parameters have been replaced with a + model parameter that takes a :class:`~openmc.model.Model` object + + Parameters + ---------- + model : openmc.model.Model + OpenMC model object + chain_file : str, optional + Path to the depletion chain XML file. Defaults to the file + listed under ``depletion_chain`` in + :envvar:`OPENMC_CROSS_SECTIONS` environment variable. + prev_results : Results, optional + Results from a previous depletion calculation. If this argument is + specified, the depletion calculation will start from the latest state + in the previous results. + diff_burnable_mats : bool, optional + Whether to differentiate burnable materials with multiple instances. + Volumes are divided equally from the original material volume. + normalization_mode : {"energy-deposition", "fission-q", "source-rate"} + Indicate how tally results should be normalized. ``"energy-deposition"`` + computes the total energy deposited in the system and uses the ratio of + the power to the energy produced as a normalization factor. + ``"fission-q"`` uses the fission Q values from the depletion chain to + compute the total energy deposited. ``"source-rate"`` normalizes + tallies based on the source rate (for fixed source calculations). + fission_q : dict, optional + Dictionary of nuclides and their fission Q values [eV]. If not given, + values will be pulled from the ``chain_file``. Only applicable + if ``"normalization_mode" == "fission-q"`` + dilute_initial : float, optional + Initial atom density [atoms/cm^3] to add for nuclides that are zero + in initial condition to ensure they exist in the decay chain. + Only done for nuclides with reaction rates. + fission_yield_mode : {"constant", "cutoff", "average"} + Key indicating what fission product yield scheme to use. The + key determines what fission energy helper is used: + + * "constant": :class:`~openmc.deplete.helpers.ConstantFissionYieldHelper` + * "cutoff": :class:`~openmc.deplete.helpers.FissionYieldCutoffHelper` + * "average": :class:`~openmc.deplete.helpers.AveragedFissionYieldHelper` + + The documentation on these classes describe their methodology + and differences. Default: ``"constant"`` + fission_yield_opts : dict of str to option, optional + Optional arguments to pass to the helper determined by + ``fission_yield_mode``. Will be passed directly on to the + helper. Passing a value of None will use the defaults for + the associated helper. + reaction_rate_mode : {"direct", "flux"}, optional + Indicate how one-group reaction rates should be calculated. The "direct" + method tallies transmutation reaction rates directly. The "flux" method + tallies a multigroup flux spectrum and then collapses one-group reaction + rates after a transport solve (with an option to tally some reaction + rates directly). + + .. versionadded:: 0.12.1 + reaction_rate_opts : dict, optional + Keyword arguments that are passed to the reaction rate helper class. + When ``reaction_rate_mode`` is set to "flux", energy group boundaries + can be set using the "energies" key. See the + :class:`~openmc.deplete.helpers.FluxCollapseHelper` class for all + options. + + .. versionadded:: 0.12.1 + reduce_chain : bool, optional + If True, use :meth:`openmc.deplete.Chain.reduce` to reduce the + depletion chain up to ``reduce_chain_level``. + + .. versionadded:: 0.12 + reduce_chain_level : int, optional + Depth of the search when reducing the depletion chain. Only used + if ``reduce_chain`` evaluates to true. The default value of + ``None`` implies no limit on the depth. + + .. versionadded:: 0.12 + + Attributes + ---------- + model : openmc.model.Model + OpenMC model object + geometry : openmc.Geometry + OpenMC geometry object + settings : openmc.Settings + OpenMC settings object + dilute_initial : float + Initial atom density [atoms/cm^3] to add for nuclides that + are zero in initial condition to ensure they exist in the decay + chain. Only done for nuclides with reaction rates. + output_dir : pathlib.Path + Path to output directory to save results. + round_number : bool + Whether or not to round output to OpenMC to 8 digits. + Useful in testing, as OpenMC is incredibly sensitive to exact values. + number : openmc.deplete.AtomNumber + Total number of atoms in simulation. + nuclides_with_data : set of str + A set listing all unique nuclides available from cross_sections.xml. + chain : openmc.deplete.Chain + The depletion chain information necessary to form matrices and tallies. + reaction_rates : openmc.deplete.ReactionRates + Reaction rates from the last operator step. + burnable_mats : list of str + All burnable material IDs + heavy_metal : float + Initial heavy metal inventory [g] + local_mats : list of str + All burnable material IDs being managed by a single process + prev_res : Results or None + Results from a previous depletion calculation. ``None`` if no + results are to be used. + cleanup_when_done : bool + Whether to finalize and clear the shared library memory when the + depletion operation is complete. Defaults to clearing the library. + """ + _fission_helpers = { + "average": AveragedFissionYieldHelper, + "constant": ConstantFissionYieldHelper, + "cutoff": FissionYieldCutoffHelper, + } + + def __init__(self, model, chain_file=None, prev_results=None, + diff_burnable_mats=False, normalization_mode="fission-q", + fission_q=None, dilute_initial=1.0e3, + fission_yield_mode="constant", fission_yield_opts=None, + reaction_rate_mode="direct", reaction_rate_opts=None, + reduce_chain=False, reduce_chain_level=None): + + # check for old call to constructor + if isinstance(model, openmc.Geometry): + msg = "As of version 0.13.0 openmc.deplete.CoupledOperator " \ + "requires an openmc.Model object rather than the " \ + "openmc.Geometry and openmc.Settings parameters. Please use " \ + "the geometry and settings objects passed here to create a " \ + " model with which to generate the transport Operator." + raise TypeError(msg) + + # Determine cross sections / depletion chain + cross_sections = _find_cross_sections(model) + if chain_file is None: + chain_file = _find_chain_file(cross_sections) + + check_value('fission yield mode', fission_yield_mode, + self._fission_helpers.keys()) + check_value('normalization mode', normalization_mode, + ('energy-deposition', 'fission-q', 'source-rate')) + if normalization_mode != "fission-q": + if fission_q is not None: + warn("Fission Q dictionary will not be used") + fission_q = None + self.model = model + self.settings = model.settings + self.geometry = model.geometry + + # determine set of materials in the model + if not model.materials: + model.materials = openmc.Materials( + model.geometry.get_all_materials().values() + ) + + self.cleanup_when_done = True + + if reaction_rate_opts is None: + reaction_rate_opts = {} + if fission_yield_opts is None: + fission_yield_opts = {} + helper_kwargs = { + 'reaction_rate_mode': reaction_rate_mode, + 'normalization_mode': normalization_mode, + 'fission_yield_mode': fission_yield_mode, + 'reaction_rate_opts': reaction_rate_opts, + 'fission_yield_opts': fission_yield_opts + } + + super().__init__( + model.materials, + cross_sections, + chain_file, + prev_results, + diff_burnable_mats, + fission_q, + dilute_initial, + helper_kwargs, + reduce_chain, + reduce_chain_level) + + def _differentiate_burnable_mats(self): + """Assign distribmats for each burnable material""" + + # Count the number of instances for each cell and material + self.geometry.determine_paths(instances_only=True) + + # Extract all burnable materials which have multiple instances + distribmats = set( + [mat for mat in self.materials + if mat.depletable and mat.num_instances > 1]) + + for mat in distribmats: + if mat.volume is None: + raise RuntimeError("Volume not specified for depletable " + "material with ID={}.".format(mat.id)) + mat.volume /= mat.num_instances + + if distribmats: + # Assign distribmats to cells + for cell in self.geometry.get_all_material_cells().values(): + if cell.fill in distribmats: + mat = cell.fill + cell.fill = [mat.clone() + for i in range(cell.num_instances)] + + self.materials = openmc.Materials( + self.model.geometry.get_all_materials().values() + ) + + def _load_previous_results(self): + """Load results from a previous depletion simulation""" + # Reload volumes into geometry + self.prev_res[-1].transfer_volumes(self.model) + + # Store previous results in operator + # Distribute reaction rates according to those tracked + # on this process + if comm.size != 1: + prev_results = self.prev_res + self.prev_res = Results() + mat_indexes = _distribute(range(len(self.burnable_mats))) + for res_obj in prev_results: + new_res = res_obj.distribute(self.local_mats, mat_indexes) + self.prev_res.append(new_res) + + def _get_nuclides_with_data(self, cross_sections): + """Loads cross_sections.xml file to find nuclides with neutron data + + Parameters + ---------- + cross_sections : str + Path to cross_sections.xml file + + Returns + ------- + nuclides : set of str + Set of nuclide names that have cross secton data + + """ + return _get_nuclides_with_data(cross_sections) + + def _get_helper_classes(self, helper_kwargs): + """Create the ``_rate_helper``, ``_normalization_helper``, and + ``_yield_helper`` objects. + + Parameters + ---------- + helper_kwargs : dict + Keyword arguments for helper classes + + """ + reaction_rate_mode = helper_kwargs['reaction_rate_mode'] + normalization_mode = helper_kwargs['normalization_mode'] + fission_yield_mode = helper_kwargs['fission_yield_mode'] + reaction_rate_opts = helper_kwargs['reaction_rate_opts'] + fission_yield_opts = helper_kwargs['fission_yield_opts'] + + # Get classes to assist working with tallies + if reaction_rate_mode == "direct": + self._rate_helper = DirectReactionRateHelper( + self.reaction_rates.n_nuc, self.reaction_rates.n_react) + elif reaction_rate_mode == "flux": + # Ensure energy group boundaries were specified + if 'energies' not in reaction_rate_opts: + raise ValueError( + "Energy group boundaries must be specified in the " + "reaction_rate_opts argument when reaction_rate_mode is" + "set to 'flux'.") + + self._rate_helper = FluxCollapseHelper( + self.reaction_rates.n_nuc, + self.reaction_rates.n_react, + **reaction_rate_opts + ) + else: + raise ValueError("Invalid reaction rate mode.") + + if normalization_mode == "fission-q": + self._normalization_helper = ChainFissionHelper() + elif normalization_mode == "energy-deposition": + score = "heating" if self.settings.photon_transport else "heating-local" + self._normalization_helper = EnergyScoreHelper(score) + else: + self._normalization_helper = SourceRateHelper() + + # Select and create fission yield helper + fission_helper = self._fission_helpers[fission_yield_mode] + self._yield_helper = fission_helper.from_operator( + self, **fission_yield_opts) + + def initial_condition(self): + """Performs final setup and returns initial condition. + + Returns + ------- + list of numpy.ndarray + Total density for initial conditions. + + """ + + # Create XML files + if comm.rank == 0: + self.geometry.export_to_xml() + self.settings.export_to_xml() + self._generate_materials_xml() + + # Initialize OpenMC library + comm.barrier() + if not openmc.lib.is_initialized: + openmc.lib.init(intracomm=comm) + + # Generate tallies in memory + materials = [openmc.lib.materials[int(i)] for i in self.burnable_mats] + + return super().initial_condition(materials) + + def _generate_materials_xml(self): + """Creates materials.xml from self.number. + + Due to uncertainty with how MPI interacts with OpenMC API, this + constructs the XML manually. The long term goal is to do this + through direct memory writing. + + """ + # Sort nuclides according to order in AtomNumber object + nuclides = list(self.number.nuclides) + for mat in self.materials: + mat._nuclides.sort(key=lambda x: nuclides.index(x[0])) + + self.materials.export_to_xml() + + def __call__(self, vec, source_rate): + """Runs a simulation. + + Simulation will abort under the following circumstances: + + 1) No energy is computed using OpenMC tallies. + + Parameters + ---------- + vec : list of numpy.ndarray + Total atoms to be used in function. + source_rate : float + Power in [W] or source rate in [neutron/sec] + + Returns + ------- + openmc.deplete.OperatorResult + Eigenvalue and reaction rates resulting from transport operator + + """ + # Reset results in OpenMC + openmc.lib.reset() + + self._update_materials_and_nuclides(vec) + + # If the source rate is zero, return zero reaction rates without running + # a transport solve + if source_rate == 0.0: + rates = self.reaction_rates.copy() + rates.fill(0.0) + return OperatorResult(ufloat(0.0, 0.0), rates) + + # Run OpenMC + openmc.lib.run() + openmc.lib.reset_timers() + + # Extract results + rates = self._calculate_reaction_rates(source_rate) + + # Get k and uncertainty + keff = ufloat(*openmc.lib.keff()) + + op_result = OperatorResult(keff, rates) + + return copy.deepcopy(op_result) + + def _update_materials(self): + """Updates material compositions in OpenMC on all processes.""" + + for rank in range(comm.size): + number_i = comm.bcast(self.number, root=rank) + + for mat in number_i.materials: + nuclides = [] + densities = [] + for nuc in number_i.nuclides: + if nuc in self.nuclides_with_data: + val = 1.0e-24 * number_i.get_atom_density(mat, nuc) + + # If nuclide is zero, do not add to the problem. + if val > 0.0: + if self.round_number: + val_magnitude = np.floor(np.log10(val)) + val_scaled = val / 10**val_magnitude + val_round = round(val_scaled, 8) + + val = val_round * 10**val_magnitude + + nuclides.append(nuc) + densities.append(val) + else: + # Only output warnings if values are significantly + # negative. CRAM does not guarantee positive + # values. + if val < -1.0e-21: + print(f'WARNING: nuclide {nuc} in material' + f'{mat} is negative (density = {val}' + + ' atom/b-cm)') + + number_i[mat, nuc] = 0.0 + + # Update densities on C API side + mat_internal = openmc.lib.materials[int(mat)] + mat_internal.set_densities(nuclides, densities) + + # TODO Update densities on the Python side, otherwise the + # summary.h5 file contains densities at the first time step + + @staticmethod + def write_bos_data(step): + """Write a state-point file with beginning of step data + + Parameters + ---------- + step : int + Current depletion step including restarts + + """ + openmc.lib.statepoint_write( + "openmc_simulation_n{}.h5".format(step), + write_source=False) + + def finalize(self): + """Finalize a depletion simulation and release resources.""" + if self.cleanup_when_done: + openmc.lib.finalize() + +# Retain deprecated name for the time being +def Operator(*args, **kwargs): + # warn of name change + warn( + "The Operator(...) class has been renamed and will " + "be removed in a future version of OpenMC. Use " + "CoupledOperator(...) instead.", + FutureWarning + ) + return CoupledOperator(*args, **kwargs) diff --git a/openmc/deplete/helpers.py b/openmc/deplete/helpers.py index 33bd182d7..1e382576e 100644 --- a/openmc/deplete/helpers.py +++ b/openmc/deplete/helpers.py @@ -1,5 +1,5 @@ """ -Class for normalizing fission energy deposition +Classes for collecting and calculating quantities for reaction rate operators """ import bisect from abc import abstractmethod @@ -68,7 +68,7 @@ class TalliedFissionYieldHelper(FissionYieldHelper): mat_indexes : iterable of int Indices of tallied materials that will have their fission yields computed by this helper. Necessary as the - :class:`openmc.deplete.Operator` that uses this helper + :class:`openmc.deplete.CoupledOperator` that uses this helper may only burn a subset of all materials when running in parallel mode. """ @@ -133,9 +133,11 @@ class DirectReactionRateHelper(ReactionRateHelper): Parameters ---------- n_nucs : int - Number of burnable nuclides tracked by :class:`openmc.deplete.Operator` + Number of burnable nuclides tracked by + :class:`openmc.deplete.CoupledOperator` n_react : int - Number of reactions tracked by :class:`openmc.deplete.Operator` + Number of reactions tracked by an instance of + :class:`openmc.deplete.CoupledOperator` Attributes ---------- @@ -217,9 +219,10 @@ class FluxCollapseHelper(ReactionRateHelper): Parameters ---------- n_nucs : int - Number of burnable nuclides tracked by :class:`openmc.deplete.Operator` + Number of burnable nuclides tracked by + :class:`openmc.deplete.CoupledOperator` n_react : int - Number of reactions tracked by :class:`openmc.deplete.Operator` + Number of reactions tracked by :class:`openmc.deplete.CoupledOperator` energies : iterable of float Energy group boundaries for flux spectrum in [eV] reactions : iterable of str @@ -385,7 +388,7 @@ class ChainFissionHelper(EnergyNormalizationHelper): ---------- nuclides : list of str All nuclides with desired reaction rates. Ordered to be - consistent with :class:`openmc.deplete.Operator` + consistent with :class:`openmc.deplete.CoupledOperator` energy : float Total energy [J/s/source neutron] produced in a transport simulation. Updated in the material iteration with :meth:`update`. @@ -423,7 +426,7 @@ class ChainFissionHelper(EnergyNormalizationHelper): self._fission_q_vector = fission_qs - def update(self, fission_rates): + def update(self, fission_rates, mat_index=None): """Update energy produced with fission rates in a material Parameters @@ -432,6 +435,8 @@ class ChainFissionHelper(EnergyNormalizationHelper): fission reaction rate for each isotope in the specified material. Should be ordered corresponding to initial ``rate_index`` used in :meth:`prepare` + mat_index : int + Unused """ self._energy += dot(fission_rates, self._fission_q_vector) @@ -552,7 +557,7 @@ class ConstantFissionYieldHelper(FissionYieldHelper): Parameters ---------- - operator : openmc.deplete.TransportOperator + operator : openmc.deplete.abc.TransportOperator operator with a depletion chain kwargs: Additional keyword arguments to be used in construction @@ -610,7 +615,6 @@ class FissionYieldCutoffHelper(TalliedFissionYieldHelper): Default: 0.0253 [eV] fast_energy : float, optional Energy of yield data corresponding to fast yields. - Default: 500 [kev] Attributes ---------- @@ -632,10 +636,10 @@ class FissionYieldCutoffHelper(TalliedFissionYieldHelper): Array of fission rate fractions with shape ``(n_mats, 2, n_nucs)``. ``results[:, 0]`` corresponds to the fraction of all fissions - that occured below ``cutoff``. The number + that occurred below ``cutoff``. The number of materials in the first axis corresponds to the number of materials burned by the - :class:`openmc.deplete.Operator` + :class:`openmc.deplete.CoupledOperator` """ def __init__(self, chain_nuclides, n_bmats, cutoff=112.0, @@ -692,7 +696,7 @@ class FissionYieldCutoffHelper(TalliedFissionYieldHelper): Parameters ---------- - operator : openmc.deplete.Operator + operator : openmc.deplete.CoupledOperator Operator with a chain and burnable materials kwargs: Additional keyword arguments to be used in construction @@ -718,7 +722,7 @@ class FissionYieldCutoffHelper(TalliedFissionYieldHelper): mat_indexes : iterable of int Indices of tallied materials that will have their fission yields computed by this helper. Necessary as the - :class:`openmc.deplete.Operator` that uses this helper + :class:`openmc.deplete.CoupledOperator` that uses this helper may only burn a subset of all materials when running in parallel mode. """ @@ -788,7 +792,7 @@ class FissionYieldCutoffHelper(TalliedFissionYieldHelper): class AveragedFissionYieldHelper(TalliedFissionYieldHelper): r"""Class that computes fission yields based on average fission energy - Computes average energy at which fission events occured with + Computes average energy at which fission events occurred with .. math:: @@ -843,7 +847,7 @@ class AveragedFissionYieldHelper(TalliedFissionYieldHelper): mat_indexes : iterable of int Indices of tallied materials that will have their fission yields computed by this helper. Necessary as the - :class:`openmc.deplete.Operator` that uses this helper + :class:`openmc.deplete.CoupledOperator` that uses this helper may only burn a subset of all materials when running in parallel mode. """ @@ -906,7 +910,7 @@ class AveragedFissionYieldHelper(TalliedFissionYieldHelper): Use the computed average energy of fission events to determine fission yields. If average energy is between two sets of yields, linearly - interpolate bewteen the two. + interpolate between the two. Otherwise take the closet set of yields. Parameters @@ -956,7 +960,7 @@ class AveragedFissionYieldHelper(TalliedFissionYieldHelper): Parameters ---------- - operator : openmc.deplete.TransportOperator + operator : openmc.deplete.CoupledOperator Operator with a depletion chain kwargs : Additional keyword arguments to be used in construction diff --git a/openmc/deplete/independent_operator.py b/openmc/deplete/independent_operator.py new file mode 100644 index 000000000..b7956205a --- /dev/null +++ b/openmc/deplete/independent_operator.py @@ -0,0 +1,452 @@ +"""Transport-independent transport operator for depletion. + +This module implements a transport operator that runs independently of any +transport solver by using user-provided one-group cross sections. + +""" + +import copy +from collections import OrderedDict +from warnings import warn +from itertools import product + +import numpy as np +from uncertainties import ufloat + +import openmc +from openmc.checkvalue import check_type +from openmc.mpi import comm +from .abc import ReactionRateHelper, OperatorResult +from .openmc_operator import OpenMCOperator, _distribute +from .microxs import MicroXS +from .results import Results +from .helpers import ChainFissionHelper, ConstantFissionYieldHelper, SourceRateHelper + +class IndependentOperator(OpenMCOperator): + """Transport-independent transport operator that uses one-group cross + sections to calculate reaction rates. + + Instances of this class can be used to perform depletion using one-group + cross sections and constant flux or constant power. Normally, a user needn't + call methods of this class directly. Instead, an instance of this class is + passed to an integrator class, such as + :class:`openmc.deplete.CECMIntegrator`. + + Parameters + ---------- + materials : openmc.Materials + Materials to deplete. + micro_xs : MicroXS + One-group microscopic cross sections in [b] . + chain_file : str + Path to the depletion chain XML file. + keff : 2-tuple of float, optional + keff eigenvalue and uncertainty from transport calculation. + Default is None. + prev_results : Results, optional + Results from a previous depletion calculation. + normalization_mode : {"fission-q", "source-rate"} + Indicate how reaction rates should be calculated. + ``"fission-q"`` uses the fission Q values from the depletion chain to + compute the flux based on the power. ``"source-rate"`` uses a the + source rate (assumed to be neutron flux) to calculate the + reaction rates. + fission_q : dict, optional + Dictionary of nuclides and their fission Q values [eV]. If not given, + values will be pulled from the ``chain_file``. Only applicable + if ``"normalization_mode" == "fission-q"``. + reduce_chain : bool, optional + If True, use :meth:`openmc.deplete.Chain.reduce` to reduce the + depletion chain up to ``reduce_chain_level``. + reduce_chain_level : int, optional + Depth of the search when reducing the depletion chain. Only used + if ``reduce_chain`` evaluates to true. The default value of + ``None`` implies no limit on the depth. + fission_yield_opts : dict of str to option, optional + Optional arguments to pass to the + :class:`openmc.deplete.helpers.FissionYieldHelper` object. Will be + passed directly on to the helper. Passing a value of None will use + the defaults for the associated helper. + + Attributes + ---------- + materials : openmc.Materials + All materials present in the model + cross_sections : MicroXS + Object containing one-group cross-sections in [cm^2]. + dilute_initial : float + Initial atom density [atoms/cm^3] to add for nuclides that + are zero in initial condition to ensure they exist in the decay + chain. Only done for nuclides with reaction rates. + output_dir : pathlib.Path + Path to output directory to save results. + round_number : bool + Whether or not to round output to OpenMC to 8 digits. + Useful in testing, as OpenMC is incredibly sensitive to exact values. + number : openmc.deplete.AtomNumber + Total number of atoms in simulation. + nuclides_with_data : set of str + A set listing all unique nuclides available from cross_sections.xml. + chain : openmc.deplete.Chain + The depletion chain information necessary to form matrices and tallies. + reaction_rates : openmc.deplete.ReactionRates + Reaction rates from the last operator step. + burnable_mats : list of str + All burnable material IDs + heavy_metal : float + Initial heavy metal inventory [g] + local_mats : list of str + All burnable material IDs being managed by a single process + prev_res : Results or None + Results from a previous depletion calculation. ``None`` if no + results are to be used. + + """ + + def __init__(self, + materials, + micro_xs, + chain_file, + keff=None, + normalization_mode='source-rate', + fission_q=None, + prev_results=None, + reduce_chain=False, + reduce_chain_level=None, + fission_yield_opts=None): + # Validate micro-xs parameters + check_type('materials', materials, openmc.Materials) + check_type('micro_xs', micro_xs, MicroXS) + if keff is not None: + check_type('keff', keff, tuple, float) + keff = ufloat(*keff) + + self._keff = keff + + if fission_yield_opts is None: + fission_yield_opts = {} + helper_kwargs = {'normalization_mode': normalization_mode, + 'fission_yield_opts': fission_yield_opts} + + cross_sections = micro_xs * 1e-24 + super().__init__( + materials, + cross_sections, + chain_file, + prev_results, + fission_q=fission_q, + helper_kwargs=helper_kwargs, + reduce_chain=reduce_chain, + reduce_chain_level=reduce_chain_level) + + @classmethod + def from_nuclides(cls, volume, nuclides, + micro_xs, + chain_file, + nuc_units='atom/b-cm', + keff=None, + normalization_mode='source-rate', + fission_q=None, + prev_results=None, + reduce_chain=False, + reduce_chain_level=None, + fission_yield_opts=None): + """ + Alternate constructor from a dictionary of nuclide concentrations + + volume : float + Volume of the material being depleted in [cm^3] + nuclides : dict of str to float + Dictionary with nuclide names as keys and nuclide concentrations as + values. + micro_xs : MicroXS + One-group microscopic cross sections. + chain_file : str + Path to the depletion chain XML file. + nuc_units : {'atom/cm3', 'atom/b-cm'} + Units for nuclide concentration. + keff : 2-tuple of float, optional + keff eigenvalue and uncertainty from transport calculation. + Default is None. + normalization_mode : {"fission-q", "source-rate"} + Indicate how reaction rates should be calculated. + ``"fission-q"`` uses the fission Q values from the depletion + chain to compute the flux based on the power. ``"source-rate"`` uses + the source rate (assumed to be neutron flux) to calculate the + reaction rates. + fission_q : dict, optional + Dictionary of nuclides and their fission Q values [eV]. If not + given, values will be pulled from the ``chain_file``. Only + applicable if ``"normalization_mode" == "fission-q"``. + prev_results : Results, optional + Results from a previous depletion calculation. + reduce_chain : bool, optional + If True, use :meth:`openmc.deplete.Chain.reduce` to reduce the + depletion chain up to ``reduce_chain_level``. Default is False. + reduce_chain_level : int, optional + Depth of the search when reducing the depletion chain. Only used + if ``reduce_chain`` evaluates to true. The default value of + ``None`` implies no limit on the depth. + fission_yield_opts : dict of str to option, optional + Optional arguments to pass to the + :class:`openmc.deplete.helpers.FissionYieldHelper` class. Will be + passed directly on to the helper. Passing a value of None will use + the defaults for the associated helper. + + """ + check_type('nuclides', nuclides, dict, str) + materials = cls._consolidate_nuclides_to_material(nuclides, nuc_units, volume) + return cls(materials, + micro_xs, + chain_file, + keff=keff, + normalization_mode=normalization_mode, + fission_q=fission_q, + prev_results=prev_results, + reduce_chain=reduce_chain, + reduce_chain_level=reduce_chain_level, + fission_yield_opts=fission_yield_opts) + + + @staticmethod + def _consolidate_nuclides_to_material(nuclides, nuc_units, volume): + """Puts nuclide list into an openmc.Materials object. + + """ + openmc.reset_auto_ids() + mat = openmc.Material() + if nuc_units == 'atom/b-cm': + for nuc, conc in nuclides.items(): + mat.add_nuclide(nuc, conc) + elif nuc_units == 'atom/cm3': + for nuc, conc in nuclides.items(): + mat.add_nuclide(nuc, conc * 1e-24) # convert to at/b-cm + else: + raise ValueError(f"Unit '{nuc_units}' is invalid.") + + mat.volume = volume + mat.depletable = True + + return openmc.Materials([mat]) + + def _load_previous_results(self): + """Load results from a previous depletion simulation""" + # Reload volumes into geometry + model = openmc.Model(materials=self.materials) + self.prev_res[-1].transfer_volumes(model) + self.materials = model.materials + + + # Store previous results in operator + # Distribute reaction rates according to those tracked + # on this process + if comm.size != 1: + prev_results = self.prev_res + self.prev_res = Results() + mat_indexes = _distribute(range(len(self.burnable_mats))) + for res_obj in prev_results: + new_res = res_obj.distribute(self.local_mats, mat_indexes) + self.prev_res.append(new_res) + + + def _get_nuclides_with_data(self, cross_sections): + """Finds nuclides with cross section data""" + return set(cross_sections.index) + + class _IndependentNormalizationHelper(ChainFissionHelper): + """Class for calculating one-group flux based on a power. + + flux = Power / X, where X = volume * sum_i(Q_i * fission_micro_xs_i * density_i) + + Parameters + ---------- + op : openmc.deplete.IndependentOperator + Reference to the object encapsulating _IndependentNormalizationHelper. + We pass this so we don't have to duplicate :attr:`IndependentOperator.number`. + + """ + + def __init__(self, op): + rates = op.reaction_rates + self.nuc_ind_map = {ind: nuc for nuc, ind in rates.index_nuc.items()} + self._op = op + super().__init__() + + def update(self, fission_rates, mat_index=None): + """Update 'energy' produced with fission rates in a material. What + this actually calculates is the quantity X. + + Parameters + ---------- + fission_rates : numpy.ndarray + fission reaction rate for each isotope in the specified + material. Should be ordered corresponding to initial + ``rate_index`` used in :meth:`prepare` + mat_index : int + Material index + + """ + volume = self._op.number.get_mat_volume(mat_index) + densities = np.empty_like(fission_rates) + for i_nuc, nuc in self.nuc_ind_map.items(): + densities[i_nuc] = self._op.number.get_atom_density(mat_index, nuc) + fission_rates *= volume * densities + + super().update(fission_rates) + + class _IndependentRateHelper(ReactionRateHelper): + """Class for generating one-group reaction rates with flux and + one-group cross sections. + + This class does not generate tallies, and instead stores cross sections + for each nuclide and transmutation reaction relevant for a depletion + calculation. The reaction rate is calculated by multiplying the flux by the + cross sections. + + Parameters + ---------- + op : openmc.deplete.IndependentOperator + Reference to the object encapsulate _IndependentRateHelper. + We pass this so we don't have to duplicate the :attr:`IndependentOperator.number` object. + + + Attributes + ---------- + nuc_ind_map : dict of int to str + Dictionary mapping the nuclide index to nuclide name + rxn_ind_map : dict of int to str + Dictionary mapping reaction index to reaction name + + """ + + def __init__(self, op): + rates = op.reaction_rates + super().__init__(rates.n_nuc, rates.n_react) + + self.nuc_ind_map = {ind: nuc for nuc, ind in rates.index_nuc.items()} + self.rxn_ind_map = {ind: rxn for rxn, ind in rates.index_rx.items()} + self._op = op + + def generate_tallies(self, materials, scores): + """Unused in this case""" + pass + + def get_material_rates(self, mat_id, nuc_index, react_index): + """Return 2D array of [nuclide, reaction] reaction rates + + Parameters + ---------- + mat_id : int + Unique ID for the requested material + nuc_index : list of str + Ordering of desired nuclides + react_index : list of str + Ordering of reactions + """ + self._results_cache.fill(0.0) + + volume = self._op.number.get_mat_volume(mat_id) + for i_nuc, i_react in product(nuc_index, react_index): + nuc = self.nuc_ind_map[i_nuc] + rxn = self.rxn_ind_map[i_react] + density = self._op.number.get_atom_density(mat_id, nuc) + self._results_cache[i_nuc, + i_react] = self._op.cross_sections[rxn][nuc] * density * volume + + return self._results_cache + + def _get_helper_classes(self, helper_kwargs): + """Get helper classes for calculating reaction rates and fission yields + + Parameters + ---------- + helper_kwargs : dict + Keyword arguments for helper classes + + """ + + normalization_mode = helper_kwargs['normalization_mode'] + fission_yield_opts = helper_kwargs['fission_yield_opts'] + + self._rate_helper = self._IndependentRateHelper(self) + if normalization_mode == "fission-q": + self._normalization_helper = self._IndependentNormalizationHelper(self) + else: + self._normalization_helper = SourceRateHelper() + + # Select and create fission yield helper + fission_helper = ConstantFissionYieldHelper + self._yield_helper = fission_helper.from_operator( + self, **fission_yield_opts) + + def initial_condition(self): + """Performs final setup and returns initial condition. + + Returns + ------- + list of numpy.ndarray + Total density for initial conditions. + """ + + # Return number density vector + return super().initial_condition(self.materials) + + def __call__(self, vec, source_rate): + """Obtain the reaction rates + + Parameters + ---------- + vec : list of numpy.ndarray + Total atoms to be used in function. + source_rate : float + Power in [W] or flux in [neutron/cm^2-s] + + Returns + ------- + openmc.deplete.OperatorResult + Eigenvalue and reaction rates resulting from transport operator + + """ + + self._update_materials_and_nuclides(vec) + + rates = self._calculate_reaction_rates(source_rate) + keff = self._keff + + op_result = OperatorResult(keff, rates) + return copy.deepcopy(op_result) + + def _update_materials(self): + """Updates material compositions in OpenMC on all processes.""" + + for rank in range(comm.size): + number_i = comm.bcast(self.number, root=rank) + + for mat in number_i.materials: + nuclides = [] + densities = [] + for nuc in number_i.nuclides: + if nuc in self.nuclides_with_data: + val = 1.0e-24 * number_i.get_atom_density(mat, nuc) + + # If nuclide is zero, do not add to the problem. + if val > 0.0: + if self.round_number: + val_magnitude = np.floor(np.log10(val)) + val_scaled = val / 10**val_magnitude + val_round = round(val_scaled, 8) + + val = val_round * 10**val_magnitude + + nuclides.append(nuc) + densities.append(val) + else: + # Only output warnings if values are significantly + # negative. CRAM does not guarantee positive + # values. + if val < -1.0e-21: + print(f'WARNING: nuclide {nuc} in material' + f'{mat} is negative (density = {val}' + + ' atom/b-cm)') + number_i[mat, nuc] = 0.0 diff --git a/openmc/deplete/integrators.py b/openmc/deplete/integrators.py index c19ef076a..74a3cebdb 100644 --- a/openmc/deplete/integrators.py +++ b/openmc/deplete/integrators.py @@ -103,7 +103,7 @@ class CECMIntegrator(Integrator): op_results : list of openmc.deplete.OperatorResult Eigenvalue and reaction rates from transport simulations """ - # deplete across first half of inteval + # deplete across first half of interval time0, x_middle = self._timed_deplete(conc, rates, dt / 2) res_middle = self.operator(x_middle, source_rate) diff --git a/openmc/deplete/microxs.py b/openmc/deplete/microxs.py new file mode 100644 index 000000000..c4094970f --- /dev/null +++ b/openmc/deplete/microxs.py @@ -0,0 +1,230 @@ +"""MicroXS module + +A pandas.DataFrame storing microscopic cross section data with +nuclides names as row indices and reaction names as column indices. +""" + +import tempfile +from pathlib import Path +from copy import deepcopy + +from pandas import DataFrame, read_csv, concat +import numpy as np + +from openmc.checkvalue import check_type, check_value, check_iterable_type +from openmc.mgxs import EnergyGroups, ArbitraryXS, FissionXS +from openmc.data import DataLibrary +from openmc import Tallies, StatePoint, Materials, Material + + +from .chain import Chain, REACTIONS +from .coupled_operator import _find_cross_sections, _get_nuclides_with_data + +_valid_rxns = list(REACTIONS) +_valid_rxns.append('fission') + + +class MicroXS(DataFrame): + """Stores microscopic cross section data for use in + independent depletion. + """ + + @classmethod + def from_model(cls, + model, + reaction_domain, + chain_file, + dilute_initial=1.0e3, + energy_bounds=(0, 20e6)): + """Generate a one-group cross-section dataframe using + OpenMC. Note that the ``openmc`` executable must be compiled. + + Parameters + ---------- + model : openmc.Model + OpenMC model object. Must contain geometry, materials, and settings. + reaction_domain : openmc.Material or openmc.Cell or openmc.Universe or openmc.RegularMesh + Domain in which to tally reaction rates. + chain_file : str + Path to the depletion chain XML file that will be used in depletion + simulation. Used to determine cross sections for materials not + present in the inital composition. + dilute_initial : float + Initial atom density [atoms/cm^3] to add for nuclides that + are zero in initial condition to ensure they exist in the cross + section data. Only done for nuclides with reaction rates. + reactions : list of str, optional + Reaction names to tally + energy_bound : 2-tuple of float, optional + Bounds for the energy group. + + Returns + ------- + MicroXS + Cross section data in [b] + + """ + groups = EnergyGroups(energy_bounds) + + # Set up the reaction tallies + original_tallies = model.tallies + original_materials = deepcopy(model.materials) + tallies = Tallies() + xs = {} + reactions, diluted_materials = cls._add_dilute_nuclides(chain_file, + model, + dilute_initial) + model.materials = diluted_materials + + for rxn in reactions: + if rxn == 'fission': + xs[rxn] = FissionXS(domain=reaction_domain, groups=groups, by_nuclide=True) + else: + xs[rxn] = ArbitraryXS(rxn, domain=reaction_domain, groups=groups, by_nuclide=True) + tallies += xs[rxn].tallies.values() + + model.tallies = tallies + + # create temporary run + with tempfile.TemporaryDirectory() as temp_dir: + statepoint_path = model.run(cwd=temp_dir) + + with StatePoint(statepoint_path) as sp: + for rxn in xs: + xs[rxn].load_from_statepoint(sp) + + # Build the DataFrame + series = {} + for rx in xs: + df = xs[rx].get_pandas_dataframe(xs_type='micro') + series[rx] = df.set_index('nuclide')['mean'] + + # Revert to the original tallies and materials + model.tallies = original_tallies + model.materials = original_materials + + return cls(series) + + @classmethod + def _add_dilute_nuclides(cls, chain_file, model, dilute_initial): + """ + Add nuclides not present in burnable materials that have neutron data + and are present in the depletion chain to those materials. This allows + us to tally those specific nuclides for reactions to create one-group + cross sections. + + Parameters + ---------- + chain_file : str + Path to the depletion chain XML file that will be used in depletion + simulation. Used to determine cross sections for materials not + present in the inital composition. + model : openmc.Model + Model object + dilute_initial : float + Initial atom density [atoms/cm^3] to add for nuclides that + are zero in initial condition to ensure they exist in the cross + section data. Only done for nuclides with reaction rates. + + Returns + ------- + reactions : list of str + List of reaction names + diluted_materials : openmc.Materials + :class:`openmc.Materials` object with nuclides added to burnable + materials. + """ + chain = Chain.from_xml(chain_file) + reactions = chain.reactions + cross_sections = _find_cross_sections(model) + nuclides_with_data = _get_nuclides_with_data(cross_sections) + burnable_nucs = [nuc.name for nuc in chain.nuclides + if nuc.name in nuclides_with_data] + diluted_materials = Materials() + for material in model.materials: + if material.depletable: + nuc_densities = material.get_nuclide_atom_densities() + dilute_density = 1.e-24 * dilute_initial + material.set_density('sum') + for nuc, density in nuc_densities.items(): + material.remove_nuclide(nuc) + material.add_nuclide(nuc, density) + for burn_nuc in burnable_nucs: + if burn_nuc not in nuc_densities: + material.add_nuclide(burn_nuc, + dilute_density) + diluted_materials.append(material) + + return reactions, diluted_materials + + @classmethod + def from_array(cls, nuclides, reactions, data): + """ + Creates a ``MicroXS`` object from arrays. + + Parameters + ---------- + nuclides : list of str + List of nuclide symbols for that have data for at least one + reaction. + reactions : list of str + List of reactions. All reactions must match those in + :data:`openmc.deplete.chain.REACTIONS` + data : ndarray of floats + Array containing one-group microscopic cross section values for + each nuclide and reaction. Cross section values are assumed to be + in [b]. + + Returns + ------- + MicroXS + """ + + # Validate inputs + if data.shape != (len(nuclides), len(reactions)): + raise ValueError( + f'Nuclides list of length {len(nuclides)} and ' + f'reactions array of length {len(reactions)} do not ' + f'match dimensions of data array of shape {data.shape}') + + cls._validate_micro_xs_inputs( + nuclides, reactions, data) + micro_xs = cls(index=nuclides, columns=reactions, data=data) + + return micro_xs + + @classmethod + def from_csv(cls, csv_file, **kwargs): + """ + Load a ``MicroXS`` object from a ``.csv`` file. + + Parameters + ---------- + csv_file : str + Relative path to csv-file containing microscopic cross section + data. Cross section values are assumed to be in [b] + **kwargs : dict + Keyword arguments to pass to :func:`pandas.read_csv()`. + + Returns + ------- + MicroXS + + """ + if 'float_precision' not in kwargs: + kwargs['float_precision'] = 'round_trip' + + micro_xs = cls(read_csv(csv_file, index_col=0, **kwargs)) + + cls._validate_micro_xs_inputs(list(micro_xs.index), + list(micro_xs.columns), + micro_xs.to_numpy()) + return micro_xs + + @staticmethod + def _validate_micro_xs_inputs(nuclides, reactions, data): + check_iterable_type('nuclides', nuclides, str) + check_iterable_type('reactions', reactions, str) + check_type('data', data, np.ndarray, expected_iter_type=float) + for reaction in reactions: + check_value('reactions', reaction, _valid_rxns) diff --git a/openmc/deplete/openmc_operator.py b/openmc/deplete/openmc_operator.py new file mode 100644 index 000000000..6e6f8dd8d --- /dev/null +++ b/openmc/deplete/openmc_operator.py @@ -0,0 +1,571 @@ +"""OpenMC transport operator + +This module implements functions shared by both OpenMC transport-coupled and +transport-independent transport operators. + +""" + +from abc import abstractmethod +from collections import OrderedDict + +import numpy as np + +import openmc +from openmc.mpi import comm +from .abc import TransportOperator, OperatorResult +from .atom_number import AtomNumber +from .reaction_rates import ReactionRates + +__all__ = ["OpenMCOperator", "OperatorResult"] + + +def _distribute(items): + """Distribute items across MPI communicator + + Parameters + ---------- + items : list + List of items of distribute + + Returns + ------- + list + Items assigned to process that called + + """ + min_size, extra = divmod(len(items), comm.size) + j = 0 + for i in range(comm.size): + chunk_size = min_size + int(i < extra) + if comm.rank == i: + return items[j:j + chunk_size] + j += chunk_size + + +class OpenMCOperator(TransportOperator): + """Abstract class holding OpenMC-specific functions for running + depletion calculations. + + Specific classes for running transport-coupled or transport-independent + depletion calculations are implemented as subclasses of OpenMCOperator. + + Parameters + ---------- + materials : openmc.Materials + List of all materials in the model + cross_sections : str or pandas.DataFrame + Path to continuous energy cross section library, or object containing + one-group cross-sections. + chain_file : str, optional + Path to the depletion chain XML file. Defaults to the file + listed under ``depletion_chain`` in + :envvar:`OPENMC_CROSS_SECTIONS` environment variable. + prev_results : Results, optional + Results from a previous depletion calculation. If this argument is + specified, the depletion calculation will start from the latest state + in the previous results. + diff_burnable_mats : bool, optional + Whether to differentiate burnable materials with multiple instances. + Volumes are divided equally from the original material volume. + fission_q : dict, optional + Dictionary of nuclides and their fission Q values [eV]. + dilute_initial : float, optional + Initial atom density [atoms/cm^3] to add for nuclides that are zero + in initial condition to ensure they exist in the decay chain. + Only done for nuclides with reaction rates. + helper_kwargs : dict + Keyword arguments for helper classes + reduce_chain : bool, optional + If True, use :meth:`openmc.deplete.Chain.reduce()` to reduce the + depletion chain up to ``reduce_chain_level``. + reduce_chain_level : int, optional + Depth of the search when reducing the depletion chain. Only used + if ``reduce_chain`` evaluates to true. The default value of + ``None`` implies no limit on the depth. + + + Attributes + ---------- + materials : openmc.Materials + All materials present in the model + cross_sections : str or MicroXS + Path to continuous energy cross section library, or object + containing one-group cross-sections. + dilute_initial : float + Initial atom density [atoms/cm^3] to add for nuclides that + are zero in initial condition to ensure they exist in the decay + chain. Only done for nuclides with reaction rates. + output_dir : pathlib.Path + Path to output directory to save results. + round_number : bool + Whether or not to round output to OpenMC to 8 digits. + Useful in testing, as OpenMC is incredibly sensitive to exact values. + number : openmc.deplete.AtomNumber + Total number of atoms in simulation. + nuclides_with_data : set of str + A set listing all unique nuclides available from cross_sections.xml. + chain : openmc.deplete.Chain + The depletion chain information necessary to form matrices and tallies. + reaction_rates : openmc.deplete.ReactionRates + Reaction rates from the last operator step. + burnable_mats : list of str + All burnable material IDs + heavy_metal : float + Initial heavy metal inventory [g] + local_mats : list of str + All burnable material IDs being managed by a single process + prev_res : Results or None + Results from a previous depletion calculation. ``None`` if no + results are to be used. + + """ + + def __init__( + self, + materials=None, + cross_sections=None, + chain_file=None, + prev_results=None, + diff_burnable_mats=False, + fission_q=None, + dilute_initial=0.0, + helper_kwargs=None, + reduce_chain=False, + reduce_chain_level=None): + + super().__init__(chain_file, fission_q, dilute_initial, prev_results) + self.round_number = False + self.materials = materials + self.cross_sections = cross_sections + + # Reduce the chain to only those nuclides present + if reduce_chain: + init_nuclides = set() + for material in self.materials: + if not material.depletable: + continue + for name, _dens_percent, _dens_type in material.nuclides: + init_nuclides.add(name) + + self.chain = self.chain.reduce(init_nuclides, reduce_chain_level) + + if diff_burnable_mats: + self._differentiate_burnable_mats() + + # Determine which nuclides have cross section data + # This nuclides variables contains every nuclides + # for which there is an entry in the micro_xs parameter + openmc.reset_auto_ids() + self.burnable_mats, volumes, all_nuclides = self._get_burnable_mats() + self.local_mats = _distribute(self.burnable_mats) + + self._mat_index_map = { + lm: self.burnable_mats.index(lm) for lm in self.local_mats} + + if self.prev_res is not None: + self._load_previous_results() + + self.nuclides_with_data = self._get_nuclides_with_data( + self.cross_sections) + + # Select nuclides with data that are also in the chain + self._burnable_nucs = [nuc.name for nuc in self.chain.nuclides + if nuc.name in self.nuclides_with_data] + + # Extract number densities from the geometry / previous depletion run + self._extract_number(self.local_mats, + volumes, + all_nuclides, + self.prev_res) + + # Create reaction rates array + self.reaction_rates = ReactionRates( + self.local_mats, self._burnable_nucs, self.chain.reactions) + + self._get_helper_classes(helper_kwargs) + + def _differentiate_burnable_mats(self): + """Assign distribmats for each burnable material""" + pass + + def _get_burnable_mats(self): + """Determine depletable materials, volumes, and nuclides + + Returns + ------- + burnable_mats : list of str + List of burnable material IDs + volume : OrderedDict of str to float + Volume of each material in [cm^3] + nuclides : list of str + Nuclides in order of how they'll appear in the simulation. + + """ + + burnable_mats = set() + model_nuclides = set() + volume = OrderedDict() + + self.heavy_metal = 0.0 + + # Iterate once through the geometry to get dictionaries + for mat in self.materials: + for nuclide in mat.get_nuclides(): + model_nuclides.add(nuclide) + if mat.depletable: + burnable_mats.add(str(mat.id)) + if mat.volume is None: + raise RuntimeError("Volume not specified for depletable " + "material with ID={}.".format(mat.id)) + volume[str(mat.id)] = mat.volume + self.heavy_metal += mat.fissionable_mass + + # Make sure there are burnable materials + if not burnable_mats: + raise RuntimeError( + "No depletable materials were found in the model.") + + # Sort the sets + burnable_mats = sorted(burnable_mats, key=int) + model_nuclides = sorted(model_nuclides) + + # Construct a global nuclide dictionary, burned first + nuclides = list(self.chain.nuclide_dict) + for nuc in model_nuclides: + if nuc not in nuclides: + nuclides.append(nuc) + + return burnable_mats, volume, nuclides + + def _load_previous_results(self): + """Load results from a previous depletion simulation""" + pass + + @abstractmethod + def _get_nuclides_with_data(self, cross_sections): + """Find nuclides with cross section data + + Parameters + ---------- + cross_sections : str or pandas.DataFrame + Path to continuous energy cross section library, or object + containing one-group cross-sections. + + Returns + ------- + nuclides : set of str + Set of nuclide names that have cross secton data + + """ + + def _extract_number(self, local_mats, volume, all_nuclides, prev_res=None): + """Construct AtomNumber using geometry + + Parameters + ---------- + local_mats : list of str + Material IDs to be managed by this process + volume : OrderedDict of str to float + Volumes for the above materials in [cm^3] + all_nuclides : list of str + Nuclides to be used in the simulation. + prev_res : Results, optional + Results from a previous depletion calculation + + """ + self.number = AtomNumber(local_mats, all_nuclides, volume, len(self.chain)) + + if self.dilute_initial != 0.0: + for nuc in self._burnable_nucs: + self.number.set_atom_density( + np.s_[:], nuc, self.dilute_initial) + + # Now extract and store the number densities + # From the geometry if no previous depletion results + if prev_res is None: + for mat in self.materials: + if str(mat.id) in local_mats: + self._set_number_from_mat(mat) + + # Else from previous depletion results + else: + for mat in self.materials: + if str(mat.id) in local_mats: + self._set_number_from_results(mat, prev_res) + + def _set_number_from_mat(self, mat): + """Extracts material and number densities from openmc.Material + + Parameters + ---------- + mat : openmc.Material + The material to read from + + """ + mat_id = str(mat.id) + + for nuclide, atom_per_bcm in mat.get_nuclide_atom_densities().items(): + atom_per_cc = atom_per_bcm * 1.0e24 + self.number.set_atom_density(mat_id, nuclide, atom_per_cc) + + def _set_number_from_results(self, mat, prev_res): + """Extracts material nuclides and number densities. + + If the nuclide concentration's evolution is tracked, the densities come + from depletion results. Else, densities are extracted from the geometry + in the summary. + + Parameters + ---------- + mat : openmc.Material + The material to read from + prev_res : Results + Results from a previous depletion calculation + + """ + mat_id = str(mat.id) + + # Get nuclide lists from geometry and depletion results + depl_nuc = prev_res[-1].nuc_to_ind + geom_nuc_densities = mat.get_nuclide_atom_densities() + + # Merge lists of nuclides, with the same order for every calculation + geom_nuc_densities.update(depl_nuc) + + for nuclide, atom_per_bcm in geom_nuc_densities.items(): + if nuclide in depl_nuc: + concentration = prev_res.get_atoms(mat_id, nuclide)[1][-1] + volume = prev_res[-1].volume[mat_id] + atom_per_cc = concentration / volume + else: + atom_per_cc = atom_per_bcm * 1.0e24 + + self.number.set_atom_density(mat_id, nuclide, atom_per_cc) + + @abstractmethod + def _get_helper_classes(self, helper_kwargs): + """Create the ``_rate_helper``, ``_normalization_helper``, and + ``_yield_helper`` objects. + + Parameters + ---------- + helper_kwargs : dict + Keyword arguments for helper classes + + """ + + def initial_condition(self, materials): + """Performs final setup and returns initial condition. + + Parameters + ---------- + materials : list of str + list of material IDs + + Returns + ------- + list of numpy.ndarray + Total density for initial conditions. + + """ + + self._rate_helper.generate_tallies(materials, self.chain.reactions) + self._normalization_helper.prepare( + self.chain.nuclides, self.reaction_rates.index_nuc) + # Tell fission yield helper what materials this process is + # responsible for + self._yield_helper.generate_tallies( + materials, tuple(sorted(self._mat_index_map.values()))) + + # Return number density vector + return list(self.number.get_mat_slice(np.s_[:])) + + def _update_materials_and_nuclides(self, vec): + """Update the number density, material compositions, and nuclide + lists in helper objects + + Parameters + ---------- + vec : list of numpy.ndarray + Total atoms. + + """ + + # Update the number densities regardless of the source rate + self.number.set_density(vec) + self._update_materials() + + # Prevent OpenMC from complaining about re-creating tallies + openmc.reset_auto_ids() + + # Update tally nuclides data in preparation for transport solve + nuclides = self._get_reaction_nuclides() + self._rate_helper.nuclides = nuclides + self._normalization_helper.nuclides = nuclides + self._yield_helper.update_tally_nuclides(nuclides) + + @abstractmethod + def _update_materials(self): + """Updates material compositions in OpenMC on all processes.""" + + def write_bos_data(self, step): + """Document beginning of step data for a given step + + Called at the beginning of a depletion step and at + the final point in the simulation. + + Parameters + ---------- + step : int + Current depletion step including restarts + """ + # Since we aren't running a transport simulation, we simply pass + pass + + def _get_reaction_nuclides(self): + """Determine nuclides that should be tallied for reaction rates. + + This method returns a list of all nuclides that have cross section data + and are listed in the depletion chain. Technically, we should count + nuclides that may not appear in the depletion chain because we still + need to get the fission reaction rate for these nuclides in order to + normalize power, but that is left as a future exercise. + + Returns + ------- + list of str + Nuclides with reaction rates + + """ + nuc_set = set() + + # Create the set of all nuclides in the decay chain in materials marked + # for burning in which the number density is greater than zero. + for nuc in self.number.nuclides: + if nuc in self.nuclides_with_data: + if np.sum(self.number[:, nuc]) > 0.0: + nuc_set.add(nuc) + + # Communicate which nuclides have nonzeros to rank 0 + if comm.rank == 0: + for i in range(1, comm.size): + nuc_newset = comm.recv(source=i, tag=i) + nuc_set |= nuc_newset + else: + comm.send(nuc_set, dest=0, tag=comm.rank) + + if comm.rank == 0: + # Sort nuclides in the same order as self.number + nuc_list = [nuc for nuc in self.number.nuclides + if nuc in nuc_set] + else: + nuc_list = None + + # Store list of nuclides on each process + nuc_list = comm.bcast(nuc_list) + return [nuc for nuc in nuc_list if nuc in self.chain] + + def _calculate_reaction_rates(self, source_rate): + """Unpack tallies from OpenMC and return an operator result + + This method uses OpenMC's C API bindings to determine the k-effective + value and reaction rates from the simulation. The reaction rates are + normalized by a helper class depending on the method being used. + + Parameters + ---------- + source_rate : float + Power in [W] or source rate in [neutron/sec] + + Returns + ------- + rates : openmc.deplete.ReactionRates + Reaction rates for nuclides + + """ + rates = self.reaction_rates + rates.fill(0.0) + + # Extract reaction nuclides + rxn_nuclides = self._rate_helper.nuclides + + # Form fast map + nuc_ind = [rates.index_nuc[nuc] for nuc in rxn_nuclides] + react_ind = [rates.index_rx[react] for react in self.chain.reactions] + + # Keep track of energy produced from all reactions in eV per source + # particle + self._normalization_helper.reset() + self._yield_helper.unpack() + + # Store fission yield dictionaries + fission_yields = [] + + # Create arrays to store fission Q values, reaction rates, and nuclide + # numbers, zeroed out in material iteration + number = np.empty(rates.n_nuc) + + fission_ind = rates.index_rx.get("fission") + + # Extract results + for i, mat in enumerate(self.local_mats): + # Get tally index + mat_index = self._mat_index_map[mat] + + # Zero out reaction rates and nuclide numbers + number.fill(0.0) + + # Get new number densities + for nuc, i_nuc_results in zip(rxn_nuclides, nuc_ind): + number[i_nuc_results] = self.number[mat, nuc] + + tally_rates = self._rate_helper.get_material_rates( + mat_index, nuc_ind, react_ind) + + # Compute fission yields for this material + fission_yields.append(self._yield_helper.weighted_yields(i)) + + # Accumulate energy from fission + if fission_ind is not None: + self._normalization_helper.update( + tally_rates[:, fission_ind], + mat_index=mat_index) + + # Divide by total number and store + rates[i] = self._rate_helper.divide_by_adens(number) + + # Scale reaction rates to obtain units of reactions/sec + rates *= self._normalization_helper.factor(source_rate) + + # Store new fission yields on the chain + self.chain.fission_yields = fission_yields + + return rates + + def get_results_info(self): + """Returns volume list, material lists, and nuc lists. + + Returns + ------- + volume : dict of str float + Volumes corresponding to materials in full_burn_dict + nuc_list : list of str + A list of all nuclide names. Used for sorting the simulation. + burn_list : list of int + A list of all material IDs to be burned. Used for sorting the simulation. + full_burn_list : list + List of all burnable material IDs + + """ + nuc_list = self.number.burnable_nuclides + burn_list = self.local_mats + + volume = {} + for i, mat in enumerate(burn_list): + volume[mat] = self.number.volume[i] + + # Combine volume dictionaries across processes + volume_list = comm.allgather(volume) + volume = {k: v for d in volume_list for k, v in d.items()} + + return volume, nuc_list, burn_list, self.burnable_mats diff --git a/openmc/deplete/operator.py b/openmc/deplete/operator.py deleted file mode 100644 index 7ef874e87..000000000 --- a/openmc/deplete/operator.py +++ /dev/null @@ -1,822 +0,0 @@ -"""OpenMC transport operator - -This module implements a transport operator for OpenMC so that it can be used by -depletion integrators. The implementation makes use of the Python bindings to -OpenMC's C API so that reading tally results and updating material number -densities is all done in-memory instead of through the filesystem. - -""" - -import copy -from collections import OrderedDict -import os -from warnings import warn - -import numpy as np -from uncertainties import ufloat - -import openmc -from openmc.checkvalue import check_value -from openmc.data import DataLibrary -from openmc.exceptions import DataError -import openmc.lib -from openmc.mpi import comm -from .abc import TransportOperator, OperatorResult -from .atom_number import AtomNumber -from .chain import _find_chain_file -from .reaction_rates import ReactionRates -from .results import Results -from .helpers import ( - DirectReactionRateHelper, ChainFissionHelper, ConstantFissionYieldHelper, - FissionYieldCutoffHelper, AveragedFissionYieldHelper, EnergyScoreHelper, - SourceRateHelper, FluxCollapseHelper) - - -__all__ = ["Operator", "OperatorResult"] - - -def _distribute(items): - """Distribute items across MPI communicator - - Parameters - ---------- - items : list - List of items of distribute - - Returns - ------- - list - Items assigned to process that called - - """ - min_size, extra = divmod(len(items), comm.size) - j = 0 - for i in range(comm.size): - chunk_size = min_size + int(i < extra) - if comm.rank == i: - return items[j:j + chunk_size] - j += chunk_size - - -def _find_cross_sections(model): - """Determine cross sections to use for depletion""" - if model.materials and model.materials.cross_sections is not None: - # Prefer info from Model class if available - return model.materials.cross_sections - - # otherwise fallback to environment variable - cross_sections = os.environ.get("OPENMC_CROSS_SECTIONS") - if cross_sections is None: - raise DataError( - "Cross sections were not specified in Model.materials and " - "the OPENMC_CROSS_SECTIONS environment variable is not set." - ) - return cross_sections - - -class Operator(TransportOperator): - """OpenMC transport operator for depletion. - - Instances of this class can be used to perform depletion using OpenMC as the - transport operator. Normally, a user needn't call methods of this class - directly. Instead, an instance of this class is passed to an integrator - class, such as :class:`openmc.deplete.CECMIntegrator`. - - .. versionchanged:: 0.13.0 - The geometry and settings parameters have been replaced with a - model parameter that takes a :class:`~openmc.model.Model` object - - Parameters - ---------- - model : openmc.model.Model - OpenMC model object - chain_file : str, optional - Path to the depletion chain XML file. Defaults to the file - listed under ``depletion_chain`` in - :envvar:`OPENMC_CROSS_SECTIONS` environment variable. - prev_results : Results, optional - Results from a previous depletion calculation. If this argument is - specified, the depletion calculation will start from the latest state - in the previous results. - diff_burnable_mats : bool, optional - Whether to differentiate burnable materials with multiple instances. - Volumes are divided equally from the original material volume. - Default: False. - normalization_mode : {"energy-deposition", "fission-q", "source-rate"} - Indicate how tally results should be normalized. ``"energy-deposition"`` - computes the total energy deposited in the system and uses the ratio of - the power to the energy produced as a normalization factor. - ``"fission-q"`` uses the fission Q values from the depletion chain to - compute the total energy deposited. ``"source-rate"`` normalizes - tallies based on the source rate (for fixed source calculations). - fission_q : dict, optional - Dictionary of nuclides and their fission Q values [eV]. If not given, - values will be pulled from the ``chain_file``. Only applicable - if ``"normalization_mode" == "fission-q"`` - dilute_initial : float, optional - Initial atom density [atoms/cm^3] to add for nuclides that are zero - in initial condition to ensure they exist in the decay chain. - Only done for nuclides with reaction rates. - Defaults to 1.0e3. - fission_yield_mode : {"constant", "cutoff", "average"} - Key indicating what fission product yield scheme to use. The - key determines what fission energy helper is used: - - * "constant": :class:`~openmc.deplete.helpers.ConstantFissionYieldHelper` - * "cutoff": :class:`~openmc.deplete.helpers.FissionYieldCutoffHelper` - * "average": :class:`~openmc.deplete.helpers.AveragedFissionYieldHelper` - - The documentation on these classes describe their methodology - and differences. Default: ``"constant"`` - fission_yield_opts : dict of str to option, optional - Optional arguments to pass to the helper determined by - ``fission_yield_mode``. Will be passed directly on to the - helper. Passing a value of None will use the defaults for - the associated helper. - reaction_rate_mode : {"direct", "flux"}, optional - Indicate how one-group reaction rates should be calculated. The "direct" - method tallies transmutation reaction rates directly. The "flux" method - tallies a multigroup flux spectrum and then collapses one-group reaction - rates after a transport solve (with an option to tally some reaction - rates directly). - - .. versionadded:: 0.12.1 - reaction_rate_opts : dict, optional - Keyword arguments that are passed to the reaction rate helper class. - When ``reaction_rate_mode`` is set to "flux", energy group boundaries - can be set using the "energies" key. See the - :class:`~openmc.deplete.helpers.FluxCollapseHelper` class for all - options. - - .. versionadded:: 0.12.1 - reduce_chain : bool, optional - If True, use :meth:`openmc.deplete.Chain.reduce` to reduce the - depletion chain up to ``reduce_chain_level``. Default is False. - - .. versionadded:: 0.12 - reduce_chain_level : int, optional - Depth of the search when reducing the depletion chain. Only used - if ``reduce_chain`` evaluates to true. The default value of - ``None`` implies no limit on the depth. - - .. versionadded:: 0.12 - - Attributes - ---------- - model : openmc.model.Model - OpenMC model object - geometry : openmc.Geometry - OpenMC geometry object - settings : openmc.Settings - OpenMC settings object - dilute_initial : float - Initial atom density [atoms/cm^3] to add for nuclides that - are zero in initial condition to ensure they exist in the decay - chain. Only done for nuclides with reaction rates. - output_dir : pathlib.Path - Path to output directory to save results. - round_number : bool - Whether or not to round output to OpenMC to 8 digits. - Useful in testing, as OpenMC is incredibly sensitive to exact values. - number : openmc.deplete.AtomNumber - Total number of atoms in simulation. - nuclides_with_data : set of str - A set listing all unique nuclides available from cross_sections.xml. - chain : openmc.deplete.Chain - The depletion chain information necessary to form matrices and tallies. - reaction_rates : openmc.deplete.ReactionRates - Reaction rates from the last operator step. - burnable_mats : list of str - All burnable material IDs - heavy_metal : float - Initial heavy metal inventory [g] - local_mats : list of str - All burnable material IDs being managed by a single process - prev_res : Results or None - Results from a previous depletion calculation. ``None`` if no - results are to be used. - cleanup_when_done : bool - Whether to finalize and clear the shared library memory when the - depletion operation is complete. Defaults to clearing the library. - """ - _fission_helpers = { - "average": AveragedFissionYieldHelper, - "constant": ConstantFissionYieldHelper, - "cutoff": FissionYieldCutoffHelper, - } - - def __init__(self, model, chain_file=None, prev_results=None, - diff_burnable_mats=False, normalization_mode="fission-q", - fission_q=None, dilute_initial=1.0e3, - fission_yield_mode="constant", fission_yield_opts=None, - reaction_rate_mode="direct", reaction_rate_opts=None, - reduce_chain=False, reduce_chain_level=None): - # check for old call to constructor - if isinstance(model, openmc.Geometry): - msg = "As of version 0.13.0 openmc.deplete.Operator requires an " \ - "openmc.Model object rather than the openmc.Geometry and " \ - "openmc.Settings parameters. Please use the geometry and " \ - "settings objects passed here to create a model with which " \ - "to generate the depletion Operator." - raise TypeError(msg) - - # Determine cross sections / depletion chain - cross_sections = _find_cross_sections(model) - if chain_file is None: - chain_file = _find_chain_file(cross_sections) - - check_value('fission yield mode', fission_yield_mode, - self._fission_helpers.keys()) - check_value('normalization mode', normalization_mode, - ('energy-deposition', 'fission-q', 'source-rate')) - if normalization_mode != "fission-q": - if fission_q is not None: - warn("Fission Q dictionary will not be used") - fission_q = None - super().__init__(chain_file, fission_q, dilute_initial, prev_results) - self.round_number = False - self.model = model - self.settings = model.settings - self.geometry = model.geometry - - # determine set of materials in the model - if not model.materials: - model.materials = openmc.Materials( - model.geometry.get_all_materials().values() - ) - self.materials = model.materials - - self.cleanup_when_done = True - - # Reduce the chain before we create more materials - if reduce_chain: - all_isotopes = set() - for material in self.materials: - if not material.depletable: - continue - for name, _dens_percent, _dens_type in material.nuclides: - all_isotopes.add(name) - self.chain = self.chain.reduce(all_isotopes, reduce_chain_level) - - # Differentiate burnable materials with multiple instances - if diff_burnable_mats: - self._differentiate_burnable_mats() - self.materials = openmc.Materials( - model.geometry.get_all_materials().values() - ) - - # Clear out OpenMC, create task lists, distribute - openmc.reset_auto_ids() - self.burnable_mats, volume, nuclides = self._get_burnable_mats() - self.local_mats = _distribute(self.burnable_mats) - - # Generate map from local materials => material index - self._mat_index_map = { - lm: self.burnable_mats.index(lm) for lm in self.local_mats} - - if self.prev_res is not None: - # Reload volumes into geometry - prev_results[-1].transfer_volumes(self.model) - - # Store previous results in operator - # Distribute reaction rates according to those tracked - # on this process - if comm.size == 1: - self.prev_res = prev_results - else: - self.prev_res = Results() - mat_indexes = _distribute(range(len(self.burnable_mats))) - for res_obj in prev_results: - new_res = res_obj.distribute(self.local_mats, mat_indexes) - self.prev_res.append(new_res) - - # Determine which nuclides have incident neutron data - self.nuclides_with_data = self._get_nuclides_with_data(cross_sections) - - # Select nuclides with data that are also in the chain - self._burnable_nucs = [nuc.name for nuc in self.chain.nuclides - if nuc.name in self.nuclides_with_data] - - # Extract number densities from the geometry / previous depletion run - self._extract_number(self.local_mats, volume, nuclides, self.prev_res) - - # Create reaction rates array - self.reaction_rates = ReactionRates( - self.local_mats, self._burnable_nucs, self.chain.reactions) - - # Get classes to assist working with tallies - if reaction_rate_mode == "direct": - self._rate_helper = DirectReactionRateHelper( - self.reaction_rates.n_nuc, self.reaction_rates.n_react) - elif reaction_rate_mode == "flux": - if reaction_rate_opts is None: - reaction_rate_opts = {} - - # Ensure energy group boundaries were specified - if 'energies' not in reaction_rate_opts: - raise ValueError( - "Energy group boundaries must be specified in the " - "reaction_rate_opts argument when reaction_rate_mode is" - "set to 'flux'.") - - self._rate_helper = FluxCollapseHelper( - self.reaction_rates.n_nuc, - self.reaction_rates.n_react, - **reaction_rate_opts - ) - else: - raise ValueError("Invalid reaction rate mode.") - - if normalization_mode == "fission-q": - self._normalization_helper = ChainFissionHelper() - elif normalization_mode == "energy-deposition": - score = "heating" if self.settings.photon_transport else "heating-local" - self._normalization_helper = EnergyScoreHelper(score) - else: - self._normalization_helper = SourceRateHelper() - - # Select and create fission yield helper - fission_helper = self._fission_helpers[fission_yield_mode] - fission_yield_opts = ( - {} if fission_yield_opts is None else fission_yield_opts) - self._yield_helper = fission_helper.from_operator( - self, **fission_yield_opts) - - def __call__(self, vec, source_rate): - """Runs a simulation. - - Simulation will abort under the following circumstances: - - 1) No energy is computed using OpenMC tallies. - - Parameters - ---------- - vec : list of numpy.ndarray - Total atoms to be used in function. - source_rate : float - Power in [W] or source rate in [neutron/sec] - - Returns - ------- - openmc.deplete.OperatorResult - Eigenvalue and reaction rates resulting from transport operator - - """ - # Reset results in OpenMC - openmc.lib.reset() - - # Update the number densities regardless of the source rate - self.number.set_density(vec) - self._update_materials() - - # If the source rate is zero, return zero reaction rates without running - # a transport solve - if source_rate == 0.0: - rates = self.reaction_rates.copy() - rates.fill(0.0) - return OperatorResult(ufloat(0.0, 0.0), rates) - - # Prevent OpenMC from complaining about re-creating tallies - openmc.reset_auto_ids() - - # Update tally nuclides data in preparation for transport solve - nuclides = self._get_tally_nuclides() - self._rate_helper.nuclides = nuclides - self._normalization_helper.nuclides = nuclides - self._yield_helper.update_tally_nuclides(nuclides) - - # Run OpenMC - openmc.lib.run() - openmc.lib.reset_timers() - - # Extract results - op_result = self._unpack_tallies_and_normalize(source_rate) - - return copy.deepcopy(op_result) - - @staticmethod - def write_bos_data(step): - """Write a state-point file with beginning of step data - - Parameters - ---------- - step : int - Current depletion step including restarts - """ - openmc.lib.statepoint_write( - "openmc_simulation_n{}.h5".format(step), - write_source=False) - - def _differentiate_burnable_mats(self): - """Assign distribmats for each burnable material - - """ - - # Count the number of instances for each cell and material - self.geometry.determine_paths(instances_only=True) - - # Extract all burnable materials which have multiple instances - distribmats = set( - [mat for mat in self.materials - if mat.depletable and mat.num_instances > 1]) - - for mat in distribmats: - if mat.volume is None: - raise RuntimeError("Volume not specified for depletable " - "material with ID={}.".format(mat.id)) - mat.volume /= mat.num_instances - - if distribmats: - # Assign distribmats to cells - for cell in self.geometry.get_all_material_cells().values(): - if cell.fill in distribmats: - mat = cell.fill - cell.fill = [mat.clone() - for i in range(cell.num_instances)] - - def _get_burnable_mats(self): - """Determine depletable materials, volumes, and nuclides - - Returns - ------- - burnable_mats : list of str - List of burnable material IDs - volume : OrderedDict of str to float - Volume of each material in [cm^3] - nuclides : list of str - Nuclides in order of how they'll appear in the simulation. - - """ - - burnable_mats = set() - model_nuclides = set() - volume = OrderedDict() - - self.heavy_metal = 0.0 - - # Iterate once through the geometry to get dictionaries - for mat in self.materials: - for nuclide in mat.get_nuclides(): - model_nuclides.add(nuclide) - if mat.depletable: - burnable_mats.add(str(mat.id)) - if mat.volume is None: - raise RuntimeError("Volume not specified for depletable " - "material with ID={}.".format(mat.id)) - volume[str(mat.id)] = mat.volume - self.heavy_metal += mat.fissionable_mass - - # Make sure there are burnable materials - if not burnable_mats: - raise RuntimeError( - "No depletable materials were found in the model.") - - # Sort the sets - burnable_mats = sorted(burnable_mats, key=int) - model_nuclides = sorted(model_nuclides) - - # Construct a global nuclide dictionary, burned first - nuclides = list(self.chain.nuclide_dict) - for nuc in model_nuclides: - if nuc not in nuclides: - nuclides.append(nuc) - - return burnable_mats, volume, nuclides - - def _extract_number(self, local_mats, volume, nuclides, prev_res=None): - """Construct AtomNumber using geometry - - Parameters - ---------- - local_mats : list of str - Material IDs to be managed by this process - volume : OrderedDict of str to float - Volumes for the above materials in [cm^3] - nuclides : list of str - Nuclides to be used in the simulation. - prev_res : Results, optional - Results from a previous depletion calculation - - """ - self.number = AtomNumber(local_mats, nuclides, volume, len(self.chain)) - - if self.dilute_initial != 0.0: - for nuc in self._burnable_nucs: - self.number.set_atom_density(np.s_[:], nuc, self.dilute_initial) - - # Now extract and store the number densities - # From the geometry if no previous depletion results - if prev_res is None: - for mat in self.materials: - if str(mat.id) in local_mats: - self._set_number_from_mat(mat) - - # Else from previous depletion results - else: - for mat in self.materials: - if str(mat.id) in local_mats: - self._set_number_from_results(mat, prev_res) - - def _set_number_from_mat(self, mat): - """Extracts material and number densities from openmc.Material - - Parameters - ---------- - mat : openmc.Material - The material to read from - - """ - mat_id = str(mat.id) - - for nuclide, atom_per_bcm in mat.get_nuclide_atom_densities().items(): - atom_per_cc = atom_per_bcm * 1.0e24 - self.number.set_atom_density(mat_id, nuclide, atom_per_cc) - - def _set_number_from_results(self, mat, prev_res): - """Extracts material nuclides and number densities. - - If the nuclide concentration's evolution is tracked, the densities come - from depletion results. Else, densities are extracted from the geometry - in the summary. - - Parameters - ---------- - mat : openmc.Material - The material to read from - prev_res : Results - Results from a previous depletion calculation - - """ - mat_id = str(mat.id) - - # Get nuclide lists from geometry and depletion results - depl_nuc = prev_res[-1].nuc_to_ind - geom_nuc_densities = mat.get_nuclide_atom_densities() - - # Merge lists of nuclides, with the same order for every calculation - geom_nuc_densities.update(depl_nuc) - - for nuclide, atom_per_bcm in geom_nuc_densities.items(): - if nuclide in depl_nuc: - concentration = prev_res.get_atoms(mat_id, nuclide)[1][-1] - volume = prev_res[-1].volume[mat_id] - atom_per_cc = concentration / volume - else: - atom_per_cc = atom_per_bcm * 1.0e24 - - self.number.set_atom_density(mat_id, nuclide, atom_per_cc) - - def initial_condition(self): - """Performs final setup and returns initial condition. - - Returns - ------- - list of numpy.ndarray - Total density for initial conditions. - """ - - # Create XML files - if comm.rank == 0: - self.geometry.export_to_xml() - self.settings.export_to_xml() - self._generate_materials_xml() - - # Initialize OpenMC library - comm.barrier() - if not openmc.lib.is_initialized: - openmc.lib.init(intracomm=comm) - - # Generate tallies in memory - materials = [openmc.lib.materials[int(i)] - for i in self.burnable_mats] - self._rate_helper.generate_tallies(materials, self.chain.reactions) - self._normalization_helper.prepare( - self.chain.nuclides, self.reaction_rates.index_nuc) - # Tell fission yield helper what materials this process is - # responsible for - self._yield_helper.generate_tallies( - materials, tuple(sorted(self._mat_index_map.values()))) - - # Return number density vector - return list(self.number.get_mat_slice(np.s_[:])) - - def finalize(self): - """Finalize a depletion simulation and release resources.""" - if self.cleanup_when_done: - openmc.lib.finalize() - - def _update_materials(self): - """Updates material compositions in OpenMC on all processes.""" - - for rank in range(comm.size): - number_i = comm.bcast(self.number, root=rank) - - for mat in number_i.materials: - nuclides = [] - densities = [] - for nuc in number_i.nuclides: - if nuc in self.nuclides_with_data: - val = 1.0e-24 * number_i.get_atom_density(mat, nuc) - - # If nuclide is zero, do not add to the problem. - if val > 0.0: - if self.round_number: - val_magnitude = np.floor(np.log10(val)) - val_scaled = val / 10**val_magnitude - val_round = round(val_scaled, 8) - - val = val_round * 10**val_magnitude - - nuclides.append(nuc) - densities.append(val) - else: - # Only output warnings if values are significantly - # negative. CRAM does not guarantee positive values. - if val < -1.0e-21: - print("WARNING: nuclide ", nuc, " in material ", mat, - " is negative (density = ", val, " at/barn-cm)") - number_i[mat, nuc] = 0.0 - - # Update densities on C API side - mat_internal = openmc.lib.materials[int(mat)] - mat_internal.set_densities(nuclides, densities) - - #TODO Update densities on the Python side, otherwise the - # summary.h5 file contains densities at the first time step - - def _generate_materials_xml(self): - """Creates materials.xml from self.number. - - Due to uncertainty with how MPI interacts with OpenMC API, this - constructs the XML manually. The long term goal is to do this - through direct memory writing. - - """ - # Sort nuclides according to order in AtomNumber object - nuclides = list(self.number.nuclides) - for mat in self.materials: - mat._nuclides.sort(key=lambda x: nuclides.index(x[0])) - - self.materials.export_to_xml() - - def _get_tally_nuclides(self): - """Determine nuclides that should be tallied for reaction rates. - - This method returns a list of all nuclides that have neutron data and - are listed in the depletion chain. Technically, we should tally nuclides - that may not appear in the depletion chain because we still need to get - the fission reaction rate for these nuclides in order to normalize - power, but that is left as a future exercise. - - Returns - ------- - list of str - Tally nuclides - - """ - nuc_set = set() - - # Create the set of all nuclides in the decay chain in materials marked - # for burning in which the number density is greater than zero. - for nuc in self.number.nuclides: - if nuc in self.nuclides_with_data: - if np.sum(self.number[:, nuc]) > 0.0: - nuc_set.add(nuc) - - # Communicate which nuclides have nonzeros to rank 0 - if comm.rank == 0: - for i in range(1, comm.size): - nuc_newset = comm.recv(source=i, tag=i) - nuc_set |= nuc_newset - else: - comm.send(nuc_set, dest=0, tag=comm.rank) - - if comm.rank == 0: - # Sort nuclides in the same order as self.number - nuc_list = [nuc for nuc in self.number.nuclides - if nuc in nuc_set] - else: - nuc_list = None - - # Store list of tally nuclides on each process - nuc_list = comm.bcast(nuc_list) - return [nuc for nuc in nuc_list if nuc in self.chain] - - def _unpack_tallies_and_normalize(self, source_rate): - """Unpack tallies from OpenMC and return an operator result - - This method uses OpenMC's C API bindings to determine the k-effective - value and reaction rates from the simulation. The reaction rates are - normalized by a helper class depending on the method being used. - - Parameters - ---------- - source_rate : float - Power in [W] or source rate in [neutron/sec] - - Returns - ------- - openmc.deplete.OperatorResult - Eigenvalue and reaction rates resulting from transport operator - - """ - rates = self.reaction_rates - rates.fill(0.0) - - # Get k and uncertainty - keff = ufloat(*openmc.lib.keff()) - - # Extract tally bins - nuclides = self._rate_helper.nuclides - - # Form fast map - nuc_ind = [rates.index_nuc[nuc] for nuc in nuclides] - react_ind = [rates.index_rx[react] for react in self.chain.reactions] - - # Keep track of energy produced from all reactions in eV per source - # particle - self._normalization_helper.reset() - self._yield_helper.unpack() - - # Store fission yield dictionaries - fission_yields = [] - - # Create arrays to store fission Q values, reaction rates, and nuclide - # numbers, zeroed out in material iteration - number = np.empty(rates.n_nuc) - - fission_ind = rates.index_rx.get("fission") - - # Extract results - for i, mat in enumerate(self.local_mats): - # Get tally index - mat_index = self._mat_index_map[mat] - - # Zero out reaction rates and nuclide numbers - number.fill(0.0) - - # Get new number densities - for nuc, i_nuc_results in zip(nuclides, nuc_ind): - number[i_nuc_results] = self.number[mat, nuc] - - tally_rates = self._rate_helper.get_material_rates( - mat_index, nuc_ind, react_ind) - - # Compute fission yields for this material - fission_yields.append(self._yield_helper.weighted_yields(i)) - - # Accumulate energy from fission - if fission_ind is not None: - self._normalization_helper.update(tally_rates[:, fission_ind]) - - # Divide by total number and store - rates[i] = self._rate_helper.divide_by_adens(number) - - # Scale reaction rates to obtain units of reactions/sec - rates *= self._normalization_helper.factor(source_rate) - - # Store new fission yields on the chain - self.chain.fission_yields = fission_yields - - return OperatorResult(keff, rates) - - def _get_nuclides_with_data(self, cross_sections): - """Loads cross_sections.xml file to find nuclides with neutron data""" - nuclides = set() - data_lib = DataLibrary.from_xml(cross_sections) - for library in data_lib.libraries: - if library['type'] != 'neutron': - continue - for name in library['materials']: - if name not in nuclides: - nuclides.add(name) - - return nuclides - - def get_results_info(self): - """Returns volume list, material lists, and nuc lists. - - Returns - ------- - volume : dict of str float - Volumes corresponding to materials in full_burn_dict - nuc_list : list of str - A list of all nuclide names. Used for sorting the simulation. - burn_list : list of int - A list of all material IDs to be burned. Used for sorting the simulation. - full_burn_list : list - List of all burnable material IDs - - """ - nuc_list = self.number.burnable_nuclides - burn_list = self.local_mats - - volume = {} - for i, mat in enumerate(burn_list): - volume[mat] = self.number.volume[i] - - # Combine volume dictionaries across processes - volume_list = comm.allgather(volume) - volume = {k: v for d in volume_list for k, v in d.items()} - - return volume, nuc_list, burn_list, self.burnable_mats diff --git a/openmc/deplete/results.py b/openmc/deplete/results.py index 7e1c6431e..b89bae088 100644 --- a/openmc/deplete/results.py +++ b/openmc/deplete/results.py @@ -94,9 +94,9 @@ class Results(list): .. note:: Initial values for some isotopes that do not appear in initial concentrations may be non-zero, depending on the - value of the :attr:`openmc.deplete.Operator.dilute_initial` - attribute. The :class:`openmc.deplete.Operator` class adds isotopes - according to this setting, which can be set to zero. + value of the :attr:`openmc.deplete.CoupledOperator.dilute_initial` + attribute. The :class:`openmc.deplete.CoupledOperator` class adds + isotopes according to this setting, which can be set to zero. Parameters ---------- @@ -159,8 +159,8 @@ class Results(list): Initial values for some isotopes that do not appear in initial concentrations may be non-zero, depending on the - value of :class:`openmc.deplete.Operator` ``dilute_initial`` - The :class:`openmc.deplete.Operator` adds isotopes according + value of :class:`openmc.deplete.CoupledOperator` ``dilute_initial`` + The :class:`openmc.deplete.CoupledOperator` adds isotopes according to this setting, which can be set to zero. Parameters diff --git a/openmc/deplete/stepresult.py b/openmc/deplete/stepresult.py index 6e2373c45..c54f9edcf 100644 --- a/openmc/deplete/stepresult.py +++ b/openmc/deplete/stepresult.py @@ -462,7 +462,7 @@ class StepResult: Parameters ---------- - op : openmc.deplete.TransportOperator + op : openmc.deplete.abc.TransportOperator The operator used to generate these results. x : list of list of numpy.array The prior x vectors. Indexed [i][cell] using the above equation. @@ -495,7 +495,13 @@ class StepResult: for mat_i in range(n_mat): results[i, mat_i, :] = x[i][mat_i] - results.k = [(r.k.nominal_value, r.k.std_dev) for r in op_results] + ks = [] + for r in op_results: + if isinstance(r.k, type(None)): + ks += [(None, None)] + else: + ks += [(r.k.nominal_value, r.k.std_dev)] + results.k = ks results.rates = [r.rates for r in op_results] results.time = t results.source_rate = source_rate diff --git a/tests/micro_xs_simple.csv b/tests/micro_xs_simple.csv new file mode 100644 index 000000000..787ce74c3 --- /dev/null +++ b/tests/micro_xs_simple.csv @@ -0,0 +1,13 @@ +nuclide,"(n,gamma)",fission +U234,22.231989822002465,0.49620744663749855 +U235,10.479008971197121,48.41787337164604 +U238,0.8673334105437324,0.10467880588762352 +U236,8.65171044607122,0.31948392400019293 +O16,7.497851000107524e-05,0.0 +O17,0.0004079227797153371,0.0 +I135,6.842395323713927,0.0 +Xe135,227463.86426990604,0.0 +Xe136,0.02317896034753588,0.0 +Cs135,2.1721665580713623,0.0 +Gd157,12786.09939237018,0.0 +Gd156,3.4006085445846983,0.0 diff --git a/tests/regression_tests/deplete_no_transport/__init__.py b/tests/regression_tests/deplete_no_transport/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/regression_tests/deplete_no_transport/test.py b/tests/regression_tests/deplete_no_transport/test.py new file mode 100644 index 000000000..95b842537 --- /dev/null +++ b/tests/regression_tests/deplete_no_transport/test.py @@ -0,0 +1,106 @@ +""" Transport-free depletion test suite """ + +from pathlib import Path + +import numpy as np +import pytest +import openmc +import openmc.deplete +from openmc.deplete import IndependentOperator, MicroXS + +@pytest.fixture(scope="module") +def fuel(): + fuel = openmc.Material(name="uo2") + fuel.add_element("U", 1, percent_type="ao", enrichment=4.25) + fuel.add_element("O", 2) + fuel.set_density("g/cc", 10.4) + fuel.depletable = True + + fuel.volume = np.pi * 0.42 ** 2 + + return fuel + +@pytest.mark.parametrize("multiproc, from_nuclides, normalization_mode, power, flux", [ + (True, True,'source-rate', None, 1164719970082145.0), + (False, True, 'source-rate', None, 1164719970082145.0), + (True, True, 'fission-q', 174, None), + (False, True, 'fission-q', 174, None), + (True, False,'source-rate', None, 1164719970082145.0), + (False, False, 'source-rate', None, 1164719970082145.0), + (True, False, 'fission-q', 174, None), + (False, False, 'fission-q', 174, None)]) +def test_no_transport_from_nuclides(run_in_tmpdir, fuel, multiproc, from_nuclides, normalization_mode, power, flux): + """Transport free system test suite. + + Runs an OpenMC transport-free depletion calculation and verifies + that the outputs match a reference file. + + """ + # Create operator + micro_xs_file = Path(__file__).parents[2] / 'micro_xs_simple.csv' + micro_xs = MicroXS.from_csv(micro_xs_file) + chain_file = Path(__file__).parents[2] / 'chain_simple.xml' + + if from_nuclides: + nuclides = {} + for nuc, dens in fuel.get_nuclide_atom_densities().items(): + nuclides[nuc] = dens + + op = IndependentOperator.from_nuclides( + fuel.volume, nuclides, micro_xs, chain_file, normalization_mode=normalization_mode) + + else: + op = IndependentOperator(openmc.Materials([fuel]), micro_xs, chain_file, normalization_mode=normalization_mode) + + # Power and timesteps + dt = [30] # single step + + # Perform simulation using the predictor algorithm + openmc.deplete.pool.USE_MULTIPROCESSING = multiproc + openmc.deplete.PredictorIntegrator( + op, dt, power=power, source_rates=flux, timestep_units='d').integrate() + + # Get path to test and reference results + path_test = op.output_dir / 'depletion_results.h5' + if flux is not None: + ref_path = 'test_reference_source_rate.h5' + else: + ref_path = 'test_reference_fission_q.h5' + path_reference = Path(__file__).with_name(ref_path) + + # Load the reference/test results + res_test = openmc.deplete.Results(path_test) + res_ref = openmc.deplete.Results(path_reference) + + # Assert same mats + for mat in res_ref[0].mat_to_ind: + assert mat in res_test[0].mat_to_ind, \ + "Material {} not in new results.".format(mat) + for nuc in res_ref[0].nuc_to_ind: + assert nuc in res_test[0].nuc_to_ind, \ + "Nuclide {} not in new results.".format(nuc) + + for mat in res_test[0].mat_to_ind: + assert mat in res_ref[0].mat_to_ind, \ + "Material {} not in old results.".format(mat) + for nuc in res_test[0].nuc_to_ind: + assert nuc in res_ref[0].nuc_to_ind, \ + "Nuclide {} not in old results.".format(nuc) + + tol = 1.0e-6 + for mat in res_test[0].mat_to_ind: + for nuc in res_test[0].nuc_to_ind: + _, y_test = res_test.get_atoms(mat, nuc) + _, y_old = res_ref.get_atoms(mat, nuc) + + # Test each point + correct = True + for i, ref in enumerate(y_old): + if ref != y_test[i]: + if ref != 0.0: + correct = np.abs(y_test[i] - ref) / ref <= tol + else: + correct = False + + assert correct, "Discrepancy in mat {} and nuc {}\n{}\n{}".format( + mat, nuc, y_old, y_test) diff --git a/tests/regression_tests/deplete_no_transport/test_reference_fission_q.h5 b/tests/regression_tests/deplete_no_transport/test_reference_fission_q.h5 new file mode 100644 index 000000000..82bf11567 Binary files /dev/null and b/tests/regression_tests/deplete_no_transport/test_reference_fission_q.h5 differ diff --git a/tests/regression_tests/deplete_no_transport/test_reference_source_rate.h5 b/tests/regression_tests/deplete_no_transport/test_reference_source_rate.h5 new file mode 100644 index 000000000..88ed73b17 Binary files /dev/null and b/tests/regression_tests/deplete_no_transport/test_reference_source_rate.h5 differ diff --git a/tests/regression_tests/deplete_with_transport/__init__.py b/tests/regression_tests/deplete_with_transport/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/regression_tests/deplete/example_geometry.py b/tests/regression_tests/deplete_with_transport/example_geometry.py similarity index 100% rename from tests/regression_tests/deplete/example_geometry.py rename to tests/regression_tests/deplete_with_transport/example_geometry.py diff --git a/tests/regression_tests/deplete/last_step_reference_materials.xml b/tests/regression_tests/deplete_with_transport/last_step_reference_materials.xml similarity index 100% rename from tests/regression_tests/deplete/last_step_reference_materials.xml rename to tests/regression_tests/deplete_with_transport/last_step_reference_materials.xml diff --git a/tests/regression_tests/deplete/test.py b/tests/regression_tests/deplete_with_transport/test.py similarity index 99% rename from tests/regression_tests/deplete/test.py rename to tests/regression_tests/deplete_with_transport/test.py index 6c431fb33..0c2a4cd97 100644 --- a/tests/regression_tests/deplete/test.py +++ b/tests/regression_tests/deplete_with_transport/test.py @@ -12,7 +12,7 @@ from openmc.data import JOULE_PER_EV import openmc.deplete from tests.regression_tests import config -from example_geometry import generate_problem +from .example_geometry import generate_problem @pytest.fixture(scope="module") diff --git a/tests/regression_tests/deplete/test_reference.h5 b/tests/regression_tests/deplete_with_transport/test_reference.h5 similarity index 100% rename from tests/regression_tests/deplete/test_reference.h5 rename to tests/regression_tests/deplete_with_transport/test_reference.h5 diff --git a/tests/regression_tests/microxs/__init.py__ b/tests/regression_tests/microxs/__init.py__ new file mode 100644 index 000000000..e69de29bb diff --git a/tests/regression_tests/microxs/test.py b/tests/regression_tests/microxs/test.py new file mode 100644 index 000000000..dbfe3357b --- /dev/null +++ b/tests/regression_tests/microxs/test.py @@ -0,0 +1,52 @@ +"""Test one-group cross section generation""" +from pathlib import Path + +import numpy as np +import pytest +import openmc + +from openmc.deplete import MicroXS + +CHAIN_FILE = Path(__file__).parents[2] / "chain_simple.xml" + +@pytest.fixture(scope="module") +def model(): + fuel = openmc.Material(name="uo2") + fuel.add_element("U", 1, percent_type="ao", enrichment=4.25) + fuel.add_element("O", 2) + fuel.set_density("g/cc", 10.4) + + clad = openmc.Material(name="clad") + clad.add_element("Zr", 1) + clad.set_density("g/cc", 6) + + water = openmc.Material(name="water") + water.add_element("O", 1) + water.add_element("H", 2) + water.set_density("g/cc", 1.0) + water.add_s_alpha_beta("c_H_in_H2O") + + radii = [0.42, 0.45] + fuel.volume = np.pi * radii[0] ** 2 + + materials = openmc.Materials([fuel, clad, water]) + + pin_surfaces = [openmc.ZCylinder(r=r) for r in radii] + pin_univ = openmc.model.pin(pin_surfaces, materials) + bound_box = openmc.rectangular_prism(1.24, 1.24, boundary_type="reflective") + root_cell = openmc.Cell(fill=pin_univ, region=bound_box) + geometry = openmc.Geometry([root_cell]) + + settings = openmc.Settings() + settings.particles = 1000 + settings.inactive = 10 + settings.batches = 50 + + return openmc.Model(geometry, materials, settings) + + +def test_from_model(model): + ref_xs = MicroXS.from_csv('test_reference.csv') + test_xs = MicroXS.from_model(model, model.materials[0], CHAIN_FILE) + + np.testing.assert_allclose(test_xs, ref_xs, rtol=1e-11) diff --git a/tests/regression_tests/microxs/test_reference.csv b/tests/regression_tests/microxs/test_reference.csv new file mode 100644 index 000000000..787ce74c3 --- /dev/null +++ b/tests/regression_tests/microxs/test_reference.csv @@ -0,0 +1,13 @@ +nuclide,"(n,gamma)",fission +U234,22.231989822002465,0.49620744663749855 +U235,10.479008971197121,48.41787337164604 +U238,0.8673334105437324,0.10467880588762352 +U236,8.65171044607122,0.31948392400019293 +O16,7.497851000107524e-05,0.0 +O17,0.0004079227797153371,0.0 +I135,6.842395323713927,0.0 +Xe135,227463.86426990604,0.0 +Xe136,0.02317896034753588,0.0 +Cs135,2.1721665580713623,0.0 +Gd157,12786.09939237018,0.0 +Gd156,3.4006085445846983,0.0 diff --git a/tests/unit_tests/test_deplete_coupled_operator.py b/tests/unit_tests/test_deplete_coupled_operator.py new file mode 100644 index 000000000..6de0342b0 --- /dev/null +++ b/tests/unit_tests/test_deplete_coupled_operator.py @@ -0,0 +1,55 @@ +"""Basic unit tests for openmc.deplete.CoupledOperator instantiation + +""" + +from pathlib import Path + +import pytest +from openmc.deplete import CoupledOperator +import openmc +import numpy as np + +CHAIN_PATH = Path(__file__).parents[1] / "chain_simple.xml" + +@pytest.fixture(scope="module") +def model(): + fuel = openmc.Material(name="uo2") + fuel.add_element("U", 1, percent_type="ao", enrichment=4.25) + fuel.add_element("O", 2) + fuel.set_density("g/cc", 10.4) + + clad = openmc.Material(name="clad") + clad.add_element("Zr", 1) + clad.set_density("g/cc", 6) + + water = openmc.Material(name="water") + water.add_element("O", 1) + water.add_element("H", 2) + water.set_density("g/cc", 1.0) + water.add_s_alpha_beta("c_H_in_H2O") + + radii = [0.42, 0.45] + fuel.volume = np.pi * radii[0] ** 2 + clad.volume = np.pi * (radii[1]**2 - radii[0]**2) + water.volume = 1.24**2 - (np.pi * radii[1]**2) + + materials = openmc.Materials([fuel, clad, water]) + + pin_surfaces = [openmc.ZCylinder(r=r) for r in radii] + pin_univ = openmc.model.pin(pin_surfaces, materials) + bound_box = openmc.rectangular_prism(1.24, 1.24, boundary_type="reflective") + root_cell = openmc.Cell(fill=pin_univ, region=bound_box) + geometry = openmc.Geometry([root_cell]) + + settings = openmc.Settings() + settings.particles = 1000 + settings.inactive = 10 + settings.batches = 50 + + return openmc.Model(geometry, materials, settings) + +def test_operator_init(model): + """The test uses a temporary dummy chain. This file will be removed + at the end of the test, and only contains a depletion_chain node.""" + + CoupledOperator(model, CHAIN_PATH) diff --git a/tests/unit_tests/test_deplete_independent_operator.py b/tests/unit_tests/test_deplete_independent_operator.py new file mode 100644 index 000000000..f6cc7e200 --- /dev/null +++ b/tests/unit_tests/test_deplete_independent_operator.py @@ -0,0 +1,36 @@ +"""Basic unit tests for openmc.deplete.IndependentOperator instantiation + +""" + +from pathlib import Path + +import pytest +from openmc.deplete import IndependentOperator, MicroXS +from openmc import Material, Materials +import numpy as np + +CHAIN_PATH = Path(__file__).parents[1] / "chain_simple.xml" +ONE_GROUP_XS = Path(__file__).parents[1] / "micro_xs_simple.csv" + +def test_operator_init(): + """The test uses a temporary dummy chain. This file will be removed + at the end of the test, and only contains a depletion_chain node.""" + volume = 1 + nuclides = {'U234': 8.922411359424315e+18, + 'U235': 9.98240191860822e+20, + 'U238': 2.2192386373095893e+22, + 'U236': 4.5724195495061115e+18, + 'O16': 4.639065406771322e+22, + 'O17': 1.7588724018066158e+19} + micro_xs = MicroXS.from_csv(ONE_GROUP_XS) + IndependentOperator.from_nuclides( + volume, nuclides, micro_xs, CHAIN_PATH, nuc_units='atom/cm3') + + fuel = Material(name="uo2") + fuel.add_element("U", 1, percent_type="ao", enrichment=4.25) + fuel.add_element("O", 2) + fuel.set_density("g/cc", 10.4) + fuel.depletable=True + fuel.volume = 1 + materials = Materials([fuel]) + IndependentOperator(materials, micro_xs, CHAIN_PATH) diff --git a/tests/unit_tests/test_deplete_microxs.py b/tests/unit_tests/test_deplete_microxs.py new file mode 100644 index 000000000..557082eda --- /dev/null +++ b/tests/unit_tests/test_deplete_microxs.py @@ -0,0 +1,59 @@ +"""Basic unit tests for openmc.deplete.IndependentOperator instantiation + +Modifies and resets environment variable OPENMC_CROSS_SECTIONS +to a custom file with new depletion_chain node +""" + +from os import remove +from pathlib import Path + +import pytest +from openmc.deplete import MicroXS +import numpy as np + +ONE_GROUP_XS = Path(__file__).parents[1] / "micro_xs_simple.csv" + + +def test_from_array(): + nuclides = [ + 'U234', + 'U235', + 'U238', + 'U236', + 'O16', + 'O17', + 'I135', + 'Xe135', + 'Xe136', + 'Cs135', + 'Gd157', + 'Gd156'] + reactions = ['fission', '(n,gamma)'] + # These values are placeholders and are not at all + # physically meaningful. + data = np.array([[0.1, 0.], + [0.1, 0.], + [0.9, 0.], + [0.4, 0.], + [0., 0.], + [0., 0.], + [0., 0.1], + [0., 0.9], + [0., 0.], + [0., 0.], + [0., 0.1], + [0., 0.1]]) + + MicroXS.from_array(nuclides, reactions, data) + with pytest.raises(ValueError, match=r'Nuclides list of length \d* and ' + r'reactions array of length \d* do not ' + r'match dimensions of data array of shape \(\d*\,d*\)'): + MicroXS.from_array(nuclides, reactions, data[:, 0]) + +def test_csv(): + ref_xs = MicroXS.from_csv(ONE_GROUP_XS) + ref_xs.to_csv('temp_xs.csv') + temp_xs = MicroXS.from_csv('temp_xs.csv') + assert np.all(ref_xs == temp_xs) + remove('temp_xs.csv') + diff --git a/tests/unit_tests/test_deplete_resultslist.py b/tests/unit_tests/test_deplete_resultslist.py index 4d8808c2f..63073cf57 100644 --- a/tests/unit_tests/test_deplete_resultslist.py +++ b/tests/unit_tests/test_deplete_resultslist.py @@ -11,7 +11,7 @@ import openmc.deplete @pytest.fixture def res(): """Load the reference results""" - filename = (Path(__file__).parents[1] / 'regression_tests' / 'deplete' + filename = (Path(__file__).parents[1] / 'regression_tests' / 'deplete_with_transport' / 'test_reference.h5') return openmc.deplete.Results(filename)