Allow the use of substeps for CRAM (#3908)

Co-authored-by: Paul Romano <paul.k.romano@gmail.com>
This commit is contained in:
Perry 2026-04-22 16:24:34 -07:00 committed by GitHub
parent 1f7ac4215f
commit 2d5c50080c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 288 additions and 82 deletions

View file

@ -541,17 +541,15 @@ class Integrator(ABC):
iterable of float. Alternatively, units can be specified for each step
by passing an iterable of (value, unit) tuples.
power : float or iterable of float, optional
Power of the reactor in [W]. A single value indicates that
the power is constant over all timesteps. An iterable
indicates potentially different power levels for each timestep.
For a 2D problem, the power can be given in [W/cm] as long
as the "volume" assigned to a depletion material is actually
an area in [cm^2]. Either ``power``, ``power_density``, or
Power of the reactor in [W]. A single value indicates that the power is
constant over all timesteps. An iterable indicates potentially different
power levels for each timestep. For a 2D problem, the power can be given
in [W/cm] as long as the "volume" assigned to a depletion material is
actually an area in [cm^2]. Either ``power``, ``power_density``, or
``source_rates`` must be specified.
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 specified.
Power density of the reactor in [W/gHM]. It is multiplied by initial
heavy metal inventory to get total power if ``power`` is not specified.
source_rates : float or iterable of float, optional
Source rate in [neutron/sec] or neutron flux in [neutron/s-cm^2] for
each interval in :attr:`timesteps`
@ -563,8 +561,8 @@ class Integrator(ABC):
and 'MWd/kg' indicates that the values are given in burnup (MW-d of
energy deposited per kilogram of initial heavy metal).
solver : str or callable, optional
If a string, must be the name of the solver responsible for
solving the Bateman equations. Current options are:
If a string, must be the name of the solver responsible for solving the
Bateman equations. Current options are:
* ``cram16`` - 16th order IPF CRAM
* ``cram48`` - 48th order IPF CRAM [default]
@ -573,15 +571,22 @@ class Integrator(ABC):
:attr:`solver`.
.. versionadded:: 0.12
substeps : int, optional
Number of substeps per depletion interval. When greater than 1, each
interval is subdivided into `substeps` identical sub-intervals and LU
factorizations may be reused across them, improving accuracy for
nuclides with large decay-constant × timestep products.
.. versionadded:: 0.15.4
continue_timesteps : bool, optional
Whether or not to treat the current solve as a continuation of a
previous simulation. Defaults to `False`. When `False`, the depletion
steps provided are appended to any previous steps. If `True`, the
timesteps provided to the `Integrator` must exacly match any that
exist in the `prev_results` passed to the `Operator`. The `power`,
`power_density`, or `source_rates` must match as well. The
method of specifying `power`, `power_density`, or
`source_rates` should be the same as the initial run.
timesteps provided to the `Integrator` must exacly match any that exist
in the `prev_results` passed to the `Operator`. The `power`,
`power_density`, or `source_rates` must match as well. The method of
specifying `power`, `power_density`, or `source_rates` should be the
same as the initial run.
.. versionadded:: 0.15.1
@ -601,15 +606,19 @@ class Integrator(ABC):
:math:`\frac{\partial}{\partial t}\vec{n} = A_i\vec{n}_i` with a step
size :math:`t_i`. Can be configured using the ``solver`` argument.
User-supplied functions are expected to have the following signature:
``solver(A, n0, t) -> n1`` where
``solver(A, n0, t, substeps=1) -> n1``, where
* ``A`` is a :class:`scipy.sparse.csc_array` making up the
depletion matrix
* ``n0`` is a 1-D :class:`numpy.ndarray` of initial compositions
for a given material in atoms/cm3
* ``t`` is a float of the time step size in seconds, and
* ``n1`` is a :class:`numpy.ndarray` of compositions at the
next time step. Expected to be of the same shape as ``n0``
* ``A`` is a :class:`scipy.sparse.csc_array` making up the depletion
matrix
* ``n0`` is a 1-D :class:`numpy.ndarray` of initial compositions for
a given material in atoms/cm3
* ``t`` is a float of the time step size in seconds
* ``substeps`` is an optional integer number of substeps, and
* ``n1`` is a :class:`numpy.ndarray` of compositions at the next
time step. Expected to be of the same shape as ``n0``
Solvers that do not support multiple substeps should raise an exception
when ``substeps > 1``.
transfer_rates : openmc.deplete.TransferRates
Transfer rates for the depletion system used to model continuous
@ -632,6 +641,7 @@ class Integrator(ABC):
source_rates: Optional[Union[float, Sequence[float]]] = None,
timestep_units: str = 's',
solver: str = "cram48",
substeps: int = 1,
continue_timesteps: bool = False,
):
if continue_timesteps and operator.prev_res is None:
@ -653,6 +663,8 @@ class Integrator(ABC):
# Normalize timesteps and source rates
seconds, source_rates = _normalize_timesteps(
timesteps, source_rates, timestep_units, operator)
check_type("substeps", substeps, Integral)
check_greater_than("substeps", substeps, 0)
if continue_timesteps:
# Get timesteps and source rates from previous results
@ -684,6 +696,7 @@ class Integrator(ABC):
self.timesteps = np.asarray(seconds)
self.source_rates = np.asarray(source_rates)
self.substeps = substeps
self.transfer_rates = None
self.external_source_rates = None
@ -721,23 +734,32 @@ class Integrator(ABC):
self._solver = func
return
# Inspect arguments
if len(sig.parameters) != 3:
raise ValueError("Function {} does not support three arguments: "
"{!s}".format(func, sig))
params = list(sig.parameters.values())
for ix, param in enumerate(sig.parameters.values()):
if param.kind in {param.KEYWORD_ONLY, param.VAR_KEYWORD}:
# Inspect arguments
if len(params) != 4:
raise ValueError(
"Function {} must support four arguments "
"(A, n0, t, substeps=1): {!s}"
.format(func, sig))
for ix, param in enumerate(params):
if param.kind in {param.KEYWORD_ONLY, param.VAR_KEYWORD,
param.VAR_POSITIONAL}:
raise ValueError(
f"Keyword arguments like {ix} at position {param} are not allowed")
if len(params) == 4 and params[3].default != 1:
raise ValueError(
f"Fourth solver argument must default to 1, not {params[3].default}")
self._solver = func
def _timed_deplete(self, n, rates, dt, i=None, matrix_func=None):
start = time.time()
results = deplete(
self._solver, self.chain, n, rates, dt, i, matrix_func,
self.transfer_rates, self.external_source_rates)
self.transfer_rates, self.external_source_rates, self.substeps)
# Clip unphysical negative number densities
for r in results:
@ -1205,17 +1227,15 @@ class SIIntegrator(Integrator):
iterable of float. Alternatively, units can be specified for each step
by passing an iterable of (value, unit) tuples.
power : float or iterable of float, optional
Power of the reactor in [W]. A single value indicates that
the power is constant over all timesteps. An iterable
indicates potentially different power levels for each timestep.
For a 2D problem, the power can be given in [W/cm] as long
as the "volume" assigned to a depletion material is actually
an area in [cm^2]. Either ``power``, ``power_density``, or
Power of the reactor in [W]. A single value indicates that the power is
constant over all timesteps. An iterable indicates potentially different
power levels for each timestep. For a 2D problem, the power can be given
in [W/cm] as long as the "volume" assigned to a depletion material is
actually an area in [cm^2]. Either ``power``, ``power_density``, or
``source_rates`` must be specified.
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 specified.
Power density of the reactor in [W/gHM]. It is multiplied by initial
heavy metal inventory to get total power if ``power`` is not specified.
source_rates : float or iterable of float, optional
Source rate in [neutron/sec] or neutron flux in [neutron/s-cm^2] for
each interval in :attr:`timesteps`
@ -1227,11 +1247,11 @@ class SIIntegrator(Integrator):
that the values are given in burnup (MW-d of energy deposited per
kilogram of initial heavy metal).
n_steps : int, optional
Number of stochastic iterations per depletion interval.
Must be greater than zero. Default : 10
Number of stochastic iterations per depletion interval. Must be greater
than zero. Default : 10
solver : str or callable, optional
If a string, must be the name of the solver responsible for
solving the Bateman equations. Current options are:
If a string, must be the name of the solver responsible for solving the
Bateman equations. Current options are:
* ``cram16`` - 16th order IPF CRAM
* ``cram48`` - 48th order IPF CRAM [default]
@ -1240,16 +1260,23 @@ class SIIntegrator(Integrator):
:attr:`solver`.
.. versionadded:: 0.12
substeps : int, optional
Number of substeps per depletion interval. When greater than 1, each
interval is subdivided into `substeps` identical sub-intervals and LU
factorizations may be reused across them, improving accuracy for
nuclides with large decay-constant × timestep products.
.. versionadded:: 0.15.4
continue_timesteps : bool, optional
Whether or not to treat the current solve as a continuation of a
previous simulation. Defaults to `False`. If `False`, all time
steps and source rates will be run in an append fashion and will run
after whatever time steps exist, if any. If `True`, the timesteps
provided to the `Integrator` must match exactly those that exist
in the `prev_results` passed to the `Opereator`. The `power`,
`power_density`, or `source_rates` must match as well. The
method of specifying `power`, `power_density`, or
`source_rates` should be the same as the initial run.
previous simulation. Defaults to `False`. If `False`, all time steps and
source rates will be run in an append fashion and will run after
whatever time steps exist, if any. If `True`, the timesteps provided to
the `Integrator` must match exactly those that exist in the
`prev_results` passed to the `Opereator`. The `power`, `power_density`,
or `source_rates` must match as well. The method of specifying `power`,
`power_density`, or `source_rates` should be the same as the initial
run.
.. versionadded:: 0.15.1
@ -1270,15 +1297,19 @@ class SIIntegrator(Integrator):
:math:`\frac{\partial}{\partial t}\vec{n} = A_i\vec{n}_i` with a step
size :math:`t_i`. Can be configured using the ``solver`` argument.
User-supplied functions are expected to have the following signature:
``solver(A, n0, t) -> n1`` where
``solver(A, n0, t, substeps=1) -> n1``, where
* ``A`` is a :class:`scipy.sparse.csc_array` making up the
depletion matrix
* ``n0`` is a 1-D :class:`numpy.ndarray` of initial compositions
for a given material in atoms/cm3
* ``t`` is a float of the time step size in seconds, and
* ``n1`` is a :class:`numpy.ndarray` of compositions at the
next time step. Expected to be of the same shape as ``n0``
* ``A`` is a :class:`scipy.sparse.csc_array` making up the depletion
matrix
* ``n0`` is a 1-D :class:`numpy.ndarray` of initial compositions for
a given material in atoms/cm3
* ``t`` is a float of the time step size in seconds
* ``substeps`` is an optional integer number of substeps, and
* ``n1`` is a :class:`numpy.ndarray` of compositions at the next
time step. Expected to be of the same shape as ``n0``
Solvers that do not support multiple substeps should raise an exception
when ``substeps > 1``.
.. versionadded:: 0.12
@ -1294,13 +1325,16 @@ class SIIntegrator(Integrator):
timestep_units: str = 's',
n_steps: int = 10,
solver: str = "cram48",
substeps: int = 1,
continue_timesteps: bool = False,
):
check_type("n_steps", n_steps, Integral)
check_greater_than("n_steps", n_steps, 0)
super().__init__(
operator, timesteps, power, power_density, source_rates,
timestep_units=timestep_units, solver=solver, continue_timesteps=continue_timesteps)
timestep_units=timestep_units, solver=solver,
substeps=substeps,
continue_timesteps=continue_timesteps)
self.n_steps = n_steps
def _get_bos_data_from_operator(self, step_index, step_power, n_bos):
@ -1430,7 +1464,7 @@ class DepSystemSolver(ABC):
"""
@abstractmethod
def __call__(self, A, n0, dt):
def __call__(self, A, n0, dt, substeps=1):
"""Solve the linear system of equations for depletion
Parameters
@ -1443,6 +1477,8 @@ class DepSystemSolver(ABC):
material or an atom density
dt : float
Time [s] of the specific interval to be solved
substeps : int, optional
Number of substeps to use when the solver supports substepping.
Returns
-------

View file

@ -3,12 +3,13 @@
Implements two different forms of CRAM for use in openmc.deplete.
"""
from functools import partial
import numbers
import numpy as np
import scipy.sparse.linalg as sla
from scipy.sparse.linalg import spsolve, splu
from openmc.checkvalue import check_type, check_length
from openmc.checkvalue import check_type, check_length, check_greater_than
from .abc import DepSystemSolver
from .._sparse_compat import csc_array, eye_array
@ -24,6 +25,12 @@ class IPFCramSolver(DepSystemSolver):
Chebyshev Rational Approximation Method and Application to Burnup Equations
<https://doi.org/10.13182/NSE15-26>`_," Nucl. Sci. Eng., 182:3, 297-318.
When `substeps` > 1, the time interval is split into `substeps` identical
sub-intervals and LU factorizations are reused across them, as described
in: A. Isotalo and M. Pusa, "`Improving the Accuracy of the Chebyshev
Rational Approximation Method Using Substeps
<https://doi.org/10.13182/NSE15-67>`_," Nucl. Sci. Eng., 183:1, 65-77.
Parameters
----------
alpha : numpy.ndarray
@ -55,7 +62,7 @@ class IPFCramSolver(DepSystemSolver):
self.theta = theta
self.alpha0 = alpha0
def __call__(self, A, n0, dt):
def __call__(self, A, n0, dt, substeps=1):
"""Solve depletion equations using IPF CRAM
Parameters
@ -68,6 +75,8 @@ class IPFCramSolver(DepSystemSolver):
material or an atom density
dt : float
Time [s] of the specific interval to be solved
substeps : int, optional
Number of substeps per depletion interval.
Returns
-------
@ -75,12 +84,25 @@ class IPFCramSolver(DepSystemSolver):
Final compositions after ``dt``
"""
A = dt * csc_array(A, dtype=np.float64)
y = n0.copy()
check_type("substeps", substeps, numbers.Integral)
check_greater_than("substeps", substeps, 0)
step_dt = dt if substeps == 1 else dt / substeps
A = step_dt * csc_array(A, dtype=np.float64)
ident = eye_array(A.shape[0], format='csc')
for alpha, theta in zip(self.alpha, self.theta):
y += 2*np.real(alpha*sla.spsolve(A - theta*ident, y))
return y * self.alpha0
if substeps == 1:
solvers = [partial(spsolve, A - theta * ident) for theta in self.theta]
else:
# Pre-compute LU factorizations and reuse them across substeps.
solvers = [splu(A - theta * ident).solve for theta in self.theta]
y = n0.copy()
for _ in range(substeps):
for alpha, solve in zip(self.alpha, solvers):
y += 2 * np.real(alpha * solve(y))
y *= self.alpha0
return y
# Coefficients for IPF Cram 16

View file

@ -42,14 +42,15 @@ def _distribute(items):
j += chunk_size
def deplete(func, chain, n, rates, dt, current_timestep=None, matrix_func=None,
transfer_rates=None, external_source_rates=None, *matrix_args):
transfer_rates=None, external_source_rates=None, substeps=1,
*matrix_args):
"""Deplete materials using given reaction rates for a specified time
Parameters
----------
func : callable
Function to use to get new compositions. Expected to have the signature
``func(A, n0, t) -> n1``
``func(A, n0, t, substeps=1) -> n1``.
chain : openmc.deplete.Chain
Depletion chain
n : list of numpy.ndarray
@ -74,6 +75,8 @@ def deplete(func, chain, n, rates, dt, current_timestep=None, matrix_func=None,
External source rates for continuous removal/feed.
.. versionadded:: 0.15.3
substeps : int, optional
Number of substeps to pass to solvers that support substepping.
matrix_args: Any, optional
Additional arguments passed to matrix_func
@ -164,7 +167,7 @@ def deplete(func, chain, n, rates, dt, current_timestep=None, matrix_func=None,
# Concatenate vectors of nuclides in one
n_multi = np.concatenate(n)
n_result = func(matrix, n_multi, dt)
n_result = func(matrix, n_multi, dt, substeps)
# Split back the nuclide vector result into the original form
n_result = np.split(n_result, np.cumsum([len(i) for i in n])[:-1])
@ -198,7 +201,7 @@ def deplete(func, chain, n, rates, dt, current_timestep=None, matrix_func=None,
matrix.resize(matrix.shape[1], matrix.shape[1])
n[i] = np.append(n[i], 1.0)
inputs = zip(matrices, n, repeat(dt))
inputs = zip(matrices, n, repeat(dt), repeat(substeps))
if USE_MULTIPROCESSING:
with Pool(NUM_PROCESSES) as pool:

View file

@ -1,12 +1,15 @@
""" Tests for cram.py
"""Tests for cram.py.
Compares a few Mathematica matrix exponentials to CRAM16/CRAM48.
Tests substep accuracy against self-converged reference solutions.
"""
from pytest import approx
import numpy as np
import pytest
import scipy.sparse as sp
from openmc.deplete.cram import CRAM16, CRAM48
from pytest import approx
from openmc.deplete.cram import (CRAM16, CRAM48, Cram16Solver, Cram48Solver,
IPFCramSolver)
def test_CRAM16():
@ -35,3 +38,63 @@ def test_CRAM48():
z0 = np.array((0.904837418035960, 0.576799023327476))
assert z == approx(z0)
def test_substeps1_matches_original():
"""substeps=1 must be bitwise identical to original spsolve path."""
x = np.array([1.0, 1.0])
mat = sp.csr_matrix([[-1.0, 0.0], [-2.0, -3.0]])
dt = 0.1
z_orig = CRAM48(mat, x, dt)
z_sub1 = CRAM48(mat, x, dt, substeps=1)
np.testing.assert_array_equal(z_sub1, z_orig)
def test_substeps2_matches_two_half_steps():
"""substeps=2 must match two independent CRAM calls with dt/2."""
x = np.array([1.0, 1.0])
mat = sp.csr_matrix([[-1.0, 0.0], [-2.0, -3.0]])
dt = 1.0
# Two manual half-steps using original spsolve path
z_half = CRAM48(mat, x, dt / 2)
z_two = CRAM48(mat, z_half, dt / 2)
# Single call with substeps=2
z_sub2 = CRAM48(mat, x, dt, substeps=2)
assert z_sub2 == approx(z_two, rel=1e-12)
@pytest.mark.parametrize("substeps", [0, -1])
def test_invalid_substeps(substeps):
"""substeps must be a positive integer at call time."""
x = np.array([1.0, 1.0])
mat = sp.csr_matrix([[-1.0, 0.0], [-2.0, -3.0]])
with pytest.raises(ValueError, match="substeps"):
CRAM48(mat, x, 0.1, substeps=substeps)
def test_substeps_self_convergence():
"""Increasing substeps converges toward reference solution.
Uses CRAM16 (alpha0 ~ 2e-16) where substep convergence is visible.
CRAM48 (alpha0 ~ 2e-47) is already near machine precision for small
systems; its correctness is verified by the other substep tests.
"""
mat = sp.csr_matrix([[-1.0, 0.0], [-2.0, -3.0]])
x = np.array([1.0, 1.0])
dt = 50 # lambda*dt = 50 and 150, stresses CRAM16
n_ref = CRAM16(mat, x, dt, substeps=128)
prev_err = np.inf
for s in [1, 2, 4, 8, 16]:
n_s = CRAM16(mat, x, dt, substeps=s)
err = np.linalg.norm(n_s - n_ref) / np.linalg.norm(n_ref)
assert err < prev_err, \
f"substeps={s} error {err:.2e} not less than previous {prev_err:.2e}"
prev_err = err

View file

@ -19,7 +19,7 @@ from openmc.mpi import comm
from openmc.deplete import (
ReactionRates, StepResult, Results, OperatorResult, PredictorIntegrator,
CECMIntegrator, CF4Integrator, CELIIntegrator, EPCRK4Integrator,
LEQIIntegrator, SICELIIntegrator, SILEQIIntegrator, cram)
LEQIIntegrator, SICELIIntegrator, SILEQIIntegrator, cram, pool)
from tests import dummy_operator
@ -183,18 +183,42 @@ def test_bad_integrator_inputs():
with pytest.raises(TypeError, match=".*callable.*NoneType"):
PredictorIntegrator(op, timesteps, power=1, solver=None)
with pytest.raises(ValueError, match=".*arguments"):
with pytest.raises(ValueError, match="four arguments"):
PredictorIntegrator(op, timesteps, power=1, solver=mock_bad_solver_nargs)
with pytest.raises(ValueError, match="default to 1"):
PredictorIntegrator(op, timesteps, power=1,
solver=mock_bad_solver_fourth_required)
def mock_good_solver(A, n, t):
pass
with pytest.raises(ValueError, match="substeps"):
PredictorIntegrator(op, timesteps, power=1, substeps=0)
with pytest.raises(ValueError, match="substeps"):
PredictorIntegrator(op, timesteps, power=1, substeps=-1)
def mock_good_solver(A, n, t, substeps=1):
return n.copy()
def mock_good_solver_substeps(A, n, t, substeps=1):
return n + substeps
def mock_unsupported_substeps_solver(A, n, t, substeps=1):
if substeps > 1:
raise NotImplementedError("substeps > 1 not supported")
return n.copy()
def mock_bad_solver_nargs(A, n):
pass
def mock_bad_solver_fourth_required(A, n, t, substeps):
pass
@pytest.mark.parametrize("scheme", dummy_operator.SCHEMES)
def test_integrator(run_in_tmpdir, scheme):
"""Test the integrators against their expected values"""
@ -226,13 +250,71 @@ def test_integrator(run_in_tmpdir, scheme):
integrator = bundle.solver(operator, [0.75], 1, solver="cram16")
assert integrator.solver is cram.CRAM16
integrator = bundle.solver(operator, [0.75], 1, solver=cram.Cram48Solver,
substeps=2)
assert integrator.solver is cram.Cram48Solver
assert integrator.substeps == 2
integrator.solver = mock_good_solver
assert integrator.solver is mock_good_solver
lfunc = lambda A, n, t: mock_good_solver(A, n, t)
lfunc = lambda A, n, t, substeps=1: mock_good_solver(A, n, t, substeps)
integrator.solver = lfunc
assert integrator.solver is lfunc
integrator.solver = mock_good_solver_substeps
assert integrator.solver is mock_good_solver_substeps
def test_custom_solver_with_default_substeps(monkeypatch):
operator = dummy_operator.DummyOperator()
n = operator.initial_condition()
rates = operator(n, 1.0).rates
integrator = PredictorIntegrator(
operator, [0.75], power=1.0, solver=mock_good_solver)
monkeypatch.setattr(pool, "USE_MULTIPROCESSING", False)
_, result = integrator._timed_deplete(n, rates, 0.75)
np.testing.assert_array_equal(result[0], n[0])
def test_substep_aware_custom_solver_receives_substeps(monkeypatch):
operator = dummy_operator.DummyOperator()
n = operator.initial_condition()
rates = operator(n, 1.0).rates
integrator = PredictorIntegrator(
operator, [0.75], power=1.0, solver=mock_good_solver_substeps,
substeps=3)
monkeypatch.setattr(pool, "USE_MULTIPROCESSING", False)
_, result = integrator._timed_deplete(n, rates, 0.75)
np.testing.assert_array_equal(result[0], n[0] + 3)
def test_custom_solver_propagates_substeps_error(monkeypatch):
operator = dummy_operator.DummyOperator()
n = operator.initial_condition()
rates = operator(n, 1.0).rates
integrator = PredictorIntegrator(
operator, [0.75], power=1.0,
solver=mock_unsupported_substeps_solver, substeps=2)
monkeypatch.setattr(pool, "USE_MULTIPROCESSING", False)
with pytest.raises(NotImplementedError, match="not supported"):
integrator._timed_deplete(n, rates, 0.75)
def test_custom_solver_requires_four_args():
op = MagicMock()
op.prev_res = None
op.chain = None
op.heavy_metal = 1.0
with pytest.raises(ValueError, match="four arguments"):
PredictorIntegrator(op, [1], power=1, solver=mock_bad_solver_nargs)
@pytest.mark.parametrize("integrator", INTEGRATORS)
def test_timesteps(integrator):