Skip to content

Commit 2d5c500

Browse files
yrrepypaulromano
andauthored
Allow the use of substeps for CRAM (#3908)
Co-authored-by: Paul Romano <paul.k.romano@gmail.com>
1 parent 1f7ac42 commit 2d5c500

5 files changed

Lines changed: 287 additions & 81 deletions

File tree

openmc/deplete/abc.py

Lines changed: 97 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -541,17 +541,15 @@ class Integrator(ABC):
541541
iterable of float. Alternatively, units can be specified for each step
542542
by passing an iterable of (value, unit) tuples.
543543
power : float or iterable of float, optional
544-
Power of the reactor in [W]. A single value indicates that
545-
the power is constant over all timesteps. An iterable
546-
indicates potentially different power levels for each timestep.
547-
For a 2D problem, the power can be given in [W/cm] as long
548-
as the "volume" assigned to a depletion material is actually
549-
an area in [cm^2]. Either ``power``, ``power_density``, or
544+
Power of the reactor in [W]. A single value indicates that the power is
545+
constant over all timesteps. An iterable indicates potentially different
546+
power levels for each timestep. For a 2D problem, the power can be given
547+
in [W/cm] as long as the "volume" assigned to a depletion material is
548+
actually an area in [cm^2]. Either ``power``, ``power_density``, or
550549
``source_rates`` must be specified.
551550
power_density : float or iterable of float, optional
552-
Power density of the reactor in [W/gHM]. It is multiplied by
553-
initial heavy metal inventory to get total power if ``power``
554-
is not specified.
551+
Power density of the reactor in [W/gHM]. It is multiplied by initial
552+
heavy metal inventory to get total power if ``power`` is not specified.
555553
source_rates : float or iterable of float, optional
556554
Source rate in [neutron/sec] or neutron flux in [neutron/s-cm^2] for
557555
each interval in :attr:`timesteps`
@@ -563,8 +561,8 @@ class Integrator(ABC):
563561
and 'MWd/kg' indicates that the values are given in burnup (MW-d of
564562
energy deposited per kilogram of initial heavy metal).
565563
solver : str or callable, optional
566-
If a string, must be the name of the solver responsible for
567-
solving the Bateman equations. Current options are:
564+
If a string, must be the name of the solver responsible for solving the
565+
Bateman equations. Current options are:
568566
569567
* ``cram16`` - 16th order IPF CRAM
570568
* ``cram48`` - 48th order IPF CRAM [default]
@@ -573,15 +571,22 @@ class Integrator(ABC):
573571
:attr:`solver`.
574572
575573
.. versionadded:: 0.12
574+
substeps : int, optional
575+
Number of substeps per depletion interval. When greater than 1, each
576+
interval is subdivided into `substeps` identical sub-intervals and LU
577+
factorizations may be reused across them, improving accuracy for
578+
nuclides with large decay-constant × timestep products.
579+
580+
.. versionadded:: 0.15.4
576581
continue_timesteps : bool, optional
577582
Whether or not to treat the current solve as a continuation of a
578583
previous simulation. Defaults to `False`. When `False`, the depletion
579584
steps provided are appended to any previous steps. If `True`, the
580-
timesteps provided to the `Integrator` must exacly match any that
581-
exist in the `prev_results` passed to the `Operator`. The `power`,
582-
`power_density`, or `source_rates` must match as well. The
583-
method of specifying `power`, `power_density`, or
584-
`source_rates` should be the same as the initial run.
585+
timesteps provided to the `Integrator` must exacly match any that exist
586+
in the `prev_results` passed to the `Operator`. The `power`,
587+
`power_density`, or `source_rates` must match as well. The method of
588+
specifying `power`, `power_density`, or `source_rates` should be the
589+
same as the initial run.
585590
586591
.. versionadded:: 0.15.1
587592
@@ -601,15 +606,19 @@ class Integrator(ABC):
601606
:math:`\frac{\partial}{\partial t}\vec{n} = A_i\vec{n}_i` with a step
602607
size :math:`t_i`. Can be configured using the ``solver`` argument.
603608
User-supplied functions are expected to have the following signature:
604-
``solver(A, n0, t) -> n1`` where
609+
``solver(A, n0, t, substeps=1) -> n1``, where
610+
611+
* ``A`` is a :class:`scipy.sparse.csc_array` making up the depletion
612+
matrix
613+
* ``n0`` is a 1-D :class:`numpy.ndarray` of initial compositions for
614+
a given material in atoms/cm3
615+
* ``t`` is a float of the time step size in seconds
616+
* ``substeps`` is an optional integer number of substeps, and
617+
* ``n1`` is a :class:`numpy.ndarray` of compositions at the next
618+
time step. Expected to be of the same shape as ``n0``
605619
606-
* ``A`` is a :class:`scipy.sparse.csc_array` making up the
607-
depletion matrix
608-
* ``n0`` is a 1-D :class:`numpy.ndarray` of initial compositions
609-
for a given material in atoms/cm3
610-
* ``t`` is a float of the time step size in seconds, and
611-
* ``n1`` is a :class:`numpy.ndarray` of compositions at the
612-
next time step. Expected to be of the same shape as ``n0``
620+
Solvers that do not support multiple substeps should raise an exception
621+
when ``substeps > 1``.
613622
614623
transfer_rates : openmc.deplete.TransferRates
615624
Transfer rates for the depletion system used to model continuous
@@ -632,6 +641,7 @@ def __init__(
632641
source_rates: Optional[Union[float, Sequence[float]]] = None,
633642
timestep_units: str = 's',
634643
solver: str = "cram48",
644+
substeps: int = 1,
635645
continue_timesteps: bool = False,
636646
):
637647
if continue_timesteps and operator.prev_res is None:
@@ -653,6 +663,8 @@ def __init__(
653663
# Normalize timesteps and source rates
654664
seconds, source_rates = _normalize_timesteps(
655665
timesteps, source_rates, timestep_units, operator)
666+
check_type("substeps", substeps, Integral)
667+
check_greater_than("substeps", substeps, 0)
656668

657669
if continue_timesteps:
658670
# Get timesteps and source rates from previous results
@@ -684,6 +696,7 @@ def __init__(
684696

685697
self.timesteps = np.asarray(seconds)
686698
self.source_rates = np.asarray(source_rates)
699+
self.substeps = substeps
687700

688701
self.transfer_rates = None
689702
self.external_source_rates = None
@@ -721,23 +734,32 @@ def solver(self, func):
721734
self._solver = func
722735
return
723736

737+
params = list(sig.parameters.values())
738+
724739
# Inspect arguments
725-
if len(sig.parameters) != 3:
726-
raise ValueError("Function {} does not support three arguments: "
727-
"{!s}".format(func, sig))
740+
if len(params) != 4:
741+
raise ValueError(
742+
"Function {} must support four arguments "
743+
"(A, n0, t, substeps=1): {!s}"
744+
.format(func, sig))
728745

729-
for ix, param in enumerate(sig.parameters.values()):
730-
if param.kind in {param.KEYWORD_ONLY, param.VAR_KEYWORD}:
746+
for ix, param in enumerate(params):
747+
if param.kind in {param.KEYWORD_ONLY, param.VAR_KEYWORD,
748+
param.VAR_POSITIONAL}:
731749
raise ValueError(
732750
f"Keyword arguments like {ix} at position {param} are not allowed")
733751

752+
if len(params) == 4 and params[3].default != 1:
753+
raise ValueError(
754+
f"Fourth solver argument must default to 1, not {params[3].default}")
755+
734756
self._solver = func
735757

736758
def _timed_deplete(self, n, rates, dt, i=None, matrix_func=None):
737759
start = time.time()
738760
results = deplete(
739761
self._solver, self.chain, n, rates, dt, i, matrix_func,
740-
self.transfer_rates, self.external_source_rates)
762+
self.transfer_rates, self.external_source_rates, self.substeps)
741763

742764
# Clip unphysical negative number densities
743765
for r in results:
@@ -1205,17 +1227,15 @@ class SIIntegrator(Integrator):
12051227
iterable of float. Alternatively, units can be specified for each step
12061228
by passing an iterable of (value, unit) tuples.
12071229
power : float or iterable of float, optional
1208-
Power of the reactor in [W]. A single value indicates that
1209-
the power is constant over all timesteps. An iterable
1210-
indicates potentially different power levels for each timestep.
1211-
For a 2D problem, the power can be given in [W/cm] as long
1212-
as the "volume" assigned to a depletion material is actually
1213-
an area in [cm^2]. Either ``power``, ``power_density``, or
1230+
Power of the reactor in [W]. A single value indicates that the power is
1231+
constant over all timesteps. An iterable indicates potentially different
1232+
power levels for each timestep. For a 2D problem, the power can be given
1233+
in [W/cm] as long as the "volume" assigned to a depletion material is
1234+
actually an area in [cm^2]. Either ``power``, ``power_density``, or
12141235
``source_rates`` must be specified.
12151236
power_density : float or iterable of float, optional
1216-
Power density of the reactor in [W/gHM]. It is multiplied by
1217-
initial heavy metal inventory to get total power if ``power``
1218-
is not specified.
1237+
Power density of the reactor in [W/gHM]. It is multiplied by initial
1238+
heavy metal inventory to get total power if ``power`` is not specified.
12191239
source_rates : float or iterable of float, optional
12201240
Source rate in [neutron/sec] or neutron flux in [neutron/s-cm^2] for
12211241
each interval in :attr:`timesteps`
@@ -1227,11 +1247,11 @@ class SIIntegrator(Integrator):
12271247
that the values are given in burnup (MW-d of energy deposited per
12281248
kilogram of initial heavy metal).
12291249
n_steps : int, optional
1230-
Number of stochastic iterations per depletion interval.
1231-
Must be greater than zero. Default : 10
1250+
Number of stochastic iterations per depletion interval. Must be greater
1251+
than zero. Default : 10
12321252
solver : str or callable, optional
1233-
If a string, must be the name of the solver responsible for
1234-
solving the Bateman equations. Current options are:
1253+
If a string, must be the name of the solver responsible for solving the
1254+
Bateman equations. Current options are:
12351255
12361256
* ``cram16`` - 16th order IPF CRAM
12371257
* ``cram48`` - 48th order IPF CRAM [default]
@@ -1240,16 +1260,23 @@ class SIIntegrator(Integrator):
12401260
:attr:`solver`.
12411261
12421262
.. versionadded:: 0.12
1263+
substeps : int, optional
1264+
Number of substeps per depletion interval. When greater than 1, each
1265+
interval is subdivided into `substeps` identical sub-intervals and LU
1266+
factorizations may be reused across them, improving accuracy for
1267+
nuclides with large decay-constant × timestep products.
1268+
1269+
.. versionadded:: 0.15.4
12431270
continue_timesteps : bool, optional
12441271
Whether or not to treat the current solve as a continuation of a
1245-
previous simulation. Defaults to `False`. If `False`, all time
1246-
steps and source rates will be run in an append fashion and will run
1247-
after whatever time steps exist, if any. If `True`, the timesteps
1248-
provided to the `Integrator` must match exactly those that exist
1249-
in the `prev_results` passed to the `Opereator`. The `power`,
1250-
`power_density`, or `source_rates` must match as well. The
1251-
method of specifying `power`, `power_density`, or
1252-
`source_rates` should be the same as the initial run.
1272+
previous simulation. Defaults to `False`. If `False`, all time steps and
1273+
source rates will be run in an append fashion and will run after
1274+
whatever time steps exist, if any. If `True`, the timesteps provided to
1275+
the `Integrator` must match exactly those that exist in the
1276+
`prev_results` passed to the `Opereator`. The `power`, `power_density`,
1277+
or `source_rates` must match as well. The method of specifying `power`,
1278+
`power_density`, or `source_rates` should be the same as the initial
1279+
run.
12531280
12541281
.. versionadded:: 0.15.1
12551282
@@ -1270,15 +1297,19 @@ class SIIntegrator(Integrator):
12701297
:math:`\frac{\partial}{\partial t}\vec{n} = A_i\vec{n}_i` with a step
12711298
size :math:`t_i`. Can be configured using the ``solver`` argument.
12721299
User-supplied functions are expected to have the following signature:
1273-
``solver(A, n0, t) -> n1`` where
1300+
``solver(A, n0, t, substeps=1) -> n1``, where
1301+
1302+
* ``A`` is a :class:`scipy.sparse.csc_array` making up the depletion
1303+
matrix
1304+
* ``n0`` is a 1-D :class:`numpy.ndarray` of initial compositions for
1305+
a given material in atoms/cm3
1306+
* ``t`` is a float of the time step size in seconds
1307+
* ``substeps`` is an optional integer number of substeps, and
1308+
* ``n1`` is a :class:`numpy.ndarray` of compositions at the next
1309+
time step. Expected to be of the same shape as ``n0``
12741310
1275-
* ``A`` is a :class:`scipy.sparse.csc_array` making up the
1276-
depletion matrix
1277-
* ``n0`` is a 1-D :class:`numpy.ndarray` of initial compositions
1278-
for a given material in atoms/cm3
1279-
* ``t`` is a float of the time step size in seconds, and
1280-
* ``n1`` is a :class:`numpy.ndarray` of compositions at the
1281-
next time step. Expected to be of the same shape as ``n0``
1311+
Solvers that do not support multiple substeps should raise an exception
1312+
when ``substeps > 1``.
12821313
12831314
.. versionadded:: 0.12
12841315
@@ -1294,13 +1325,16 @@ def __init__(
12941325
timestep_units: str = 's',
12951326
n_steps: int = 10,
12961327
solver: str = "cram48",
1328+
substeps: int = 1,
12971329
continue_timesteps: bool = False,
12981330
):
12991331
check_type("n_steps", n_steps, Integral)
13001332
check_greater_than("n_steps", n_steps, 0)
13011333
super().__init__(
13021334
operator, timesteps, power, power_density, source_rates,
1303-
timestep_units=timestep_units, solver=solver, continue_timesteps=continue_timesteps)
1335+
timestep_units=timestep_units, solver=solver,
1336+
substeps=substeps,
1337+
continue_timesteps=continue_timesteps)
13041338
self.n_steps = n_steps
13051339

13061340
def _get_bos_data_from_operator(self, step_index, step_power, n_bos):
@@ -1430,7 +1464,7 @@ class DepSystemSolver(ABC):
14301464
"""
14311465

14321466
@abstractmethod
1433-
def __call__(self, A, n0, dt):
1467+
def __call__(self, A, n0, dt, substeps=1):
14341468
"""Solve the linear system of equations for depletion
14351469
14361470
Parameters
@@ -1443,6 +1477,8 @@ def __call__(self, A, n0, dt):
14431477
material or an atom density
14441478
dt : float
14451479
Time [s] of the specific interval to be solved
1480+
substeps : int, optional
1481+
Number of substeps to use when the solver supports substepping.
14461482
14471483
Returns
14481484
-------

openmc/deplete/cram.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
Implements two different forms of CRAM for use in openmc.deplete.
44
"""
55

6+
from functools import partial
67
import numbers
78

89
import numpy as np
9-
import scipy.sparse.linalg as sla
10+
from scipy.sparse.linalg import spsolve, splu
1011

11-
from openmc.checkvalue import check_type, check_length
12+
from openmc.checkvalue import check_type, check_length, check_greater_than
1213
from .abc import DepSystemSolver
1314
from .._sparse_compat import csc_array, eye_array
1415

@@ -24,6 +25,12 @@ class IPFCramSolver(DepSystemSolver):
2425
Chebyshev Rational Approximation Method and Application to Burnup Equations
2526
<https://doi.org/10.13182/NSE15-26>`_," Nucl. Sci. Eng., 182:3, 297-318.
2627
28+
When `substeps` > 1, the time interval is split into `substeps` identical
29+
sub-intervals and LU factorizations are reused across them, as described
30+
in: A. Isotalo and M. Pusa, "`Improving the Accuracy of the Chebyshev
31+
Rational Approximation Method Using Substeps
32+
<https://doi.org/10.13182/NSE15-67>`_," Nucl. Sci. Eng., 183:1, 65-77.
33+
2734
Parameters
2835
----------
2936
alpha : numpy.ndarray
@@ -55,7 +62,7 @@ def __init__(self, alpha, theta, alpha0):
5562
self.theta = theta
5663
self.alpha0 = alpha0
5764

58-
def __call__(self, A, n0, dt):
65+
def __call__(self, A, n0, dt, substeps=1):
5966
"""Solve depletion equations using IPF CRAM
6067
6168
Parameters
@@ -68,19 +75,34 @@ def __call__(self, A, n0, dt):
6875
material or an atom density
6976
dt : float
7077
Time [s] of the specific interval to be solved
78+
substeps : int, optional
79+
Number of substeps per depletion interval.
7180
7281
Returns
7382
-------
7483
numpy.ndarray
7584
Final compositions after ``dt``
7685
7786
"""
78-
A = dt * csc_array(A, dtype=np.float64)
79-
y = n0.copy()
87+
check_type("substeps", substeps, numbers.Integral)
88+
check_greater_than("substeps", substeps, 0)
89+
90+
step_dt = dt if substeps == 1 else dt / substeps
91+
A = step_dt * csc_array(A, dtype=np.float64)
8092
ident = eye_array(A.shape[0], format='csc')
81-
for alpha, theta in zip(self.alpha, self.theta):
82-
y += 2*np.real(alpha*sla.spsolve(A - theta*ident, y))
83-
return y * self.alpha0
93+
94+
if substeps == 1:
95+
solvers = [partial(spsolve, A - theta * ident) for theta in self.theta]
96+
else:
97+
# Pre-compute LU factorizations and reuse them across substeps.
98+
solvers = [splu(A - theta * ident).solve for theta in self.theta]
99+
100+
y = n0.copy()
101+
for _ in range(substeps):
102+
for alpha, solve in zip(self.alpha, solvers):
103+
y += 2 * np.real(alpha * solve(y))
104+
y *= self.alpha0
105+
return y
84106

85107

86108
# Coefficients for IPF Cram 16

0 commit comments

Comments
 (0)