# Source code for probnum.diffeq._perturbsolve_ivp

```"""Perturbation-based probabilistic ODE solver."""
import numpy as np
import scipy.integrate

from probnum import problems
from probnum.diffeq import perturbed, stepsize

__all__ = ["perturbsolve_ivp"]

METHODS = {
"RK45": scipy.integrate.RK45,
"RK23": scipy.integrate.RK23,
}
"""Implemented Scipy solvers."""

PERTURBS = {
"step-lognormal": perturbed.step.PerturbedStepSolver.construct_with_lognormal_perturbation,
"step-uniform": perturbed.step.PerturbedStepSolver.construct_with_uniform_perturbation,
}
"""Implemented perturbation-styles."""

[docs]def perturbsolve_ivp(
f,
t0,
tmax,
y0,
rng,
method="RK45",
perturb="step-lognormal",
noise_scale=10.0,
atol=1e-6,
rtol=1e-3,
step=None,
time_stops=None,
):
r"""Solve an initial value problem with a perturbation-based probabilistic ODE solver.

Parameters
----------
f :
ODE vector field.
t0 :
Initial time point.
tmax :
Final time point.
y0 :
Initial value.
rng :
Random number generator.
method :
Integration method to use.
The following are available (docs adapted from `SciPy <https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.solve_ivp.html>`_):

* `RK45` (default): Explicit Runge-Kutta method of order 5(4) [2]_.
The error is controlled assuming accuracy of the fourth-order
method, but steps are taken using the fifth-order accurate
formula (local extrapolation is done). A quartic interpolation
polynomial is used for the dense output [3]_. Can be applied in
the complex domain.
* `RK23`: Explicit Runge-Kutta method of order 3(2) [4]_. The error
is controlled assuming accuracy of the second-order method, but
steps are taken using the third-order accurate formula (local
extrapolation is done). A cubic Hermite polynomial is used for the
dense output. Can be applied in the complex domain.

Other integrators are not supported currently.
perturb :
Which perturbation style to use.
Currently, the following methods are supported:

* `step-lognormal`: Perturbed-step (aka random time-step numerical integration) method
with lognormally distributed perturbation of the step-size [1]_.
* `step-uniform`: Perturbed-step (aka random time-step numerical integration) method
with uniformly distributed perturbation of the step-size [1]_.

Other integrators are not supported currently.
noise_scale :
Scale of the perturbation. Optional. Default is 10.0. The magnitude of this parameter
significantly impacts the width of the posterior.
Whether to use adaptive steps or not. Default is `True`.
atol :
Absolute tolerance  of the adaptive step-size selection scheme.
Optional. Default is ``1e-6``.
rtol :
Relative tolerance   of the adaptive step-size selection scheme.
Optional. Default is ``1e-3``.
step :
Step size. If atol and rtol are not specified, this step-size is used for a fixed-step ODE solver.
If they are specified, this only affects the first step. Optional.
Default is None, in which case the first step is chosen as prescribed by :meth:`propose_firststep`.
time_stops: np.ndarray
Time-points through which the solver must step. Optional. Default is None.

Examples
--------
>>> from probnum.diffeq import perturbsolve_ivp
>>> import numpy as np

Solve a simple logistic ODE with fixed steps.
Per default, `perturbsolve_ivp` uses a perturbed-step solver with lognormal perturbation.

>>> rng = np.random.default_rng(seed=2)
>>>
>>> def f(t, x):
...     return 4*x*(1-x)
>>>
>>> y0 = np.array([0.15])
>>> t0, tmax = 0., 1.5
>>> solution = perturbsolve_ivp(f, t0, tmax, y0, rng=rng, step=0.25, method="RK23", adaptive=False)
>>> print(np.round(solution.states.mean, 3))
[[0.15 ]
[0.325]
[0.56 ]
[0.772]
[0.893]
[0.964]
[0.989]]

Each solution is the result of a randomly-perturbed call of an underlying Runge-Kutta solver.
Therefore, if you call it again, the output will be different:

>>> other_solution = perturbsolve_ivp(f, t0, tmax, y0, rng=rng, step=0.25, method="RK23", adaptive=False)
>>> print(np.round(other_solution.states.mean, 3))
[[0.15 ]
[0.319]
[0.57 ]
[0.785]
[0.908]
[0.968]
[0.989]]

Other methods, such as `RK45` (as well as other perturbation styles) are easily accessible.
Let us solve the same equation, with an adaptive RK45 solver that uses uniformly perturbed steps.

>>> solution = perturbsolve_ivp(f, t0, tmax, y0, rng=rng, atol=1e-5, rtol=1e-6, method="RK45", perturb="step-uniform", adaptive=True)
>>> print(np.round(solution.states.mean, 3))
[[0.15 ]
[0.152]
[0.167]
[0.26 ]
[0.431]
[0.646]
[0.849]
[0.883]
[0.915]
[0.953]
[0.976]
[0.986]]

References
----------
.. [1] Abdulle, A. and Garegnani, G..
Random time step probabilistic methods for uncertainty quantification in chaotic and geometric numerical integration.
Statistics and Computing. 2020.
.. [2] J. R. Dormand, P. J. Prince..
A family of embedded Runge-Kutta formulae.
Journal of Computational and Applied Mathematics, Vol. 6, No. 1, pp. 19-26, 1980.
.. [3] L. W. Shampine.
Some Practical Runge-Kutta Formulas.
Mathematics of Computation, Vol. 46, No. 173, pp. 135-150, 1986.
.. [4] P. Bogacki, L.F. Shampine.
A 3(2) Pair of Runge-Kutta Formulas.
Appl. Math. Lett. Vol. 2, No. 4. pp. 321-325, 1989.
"""

ivp = problems.InitialValueProblem(t0=t0, tmax=tmax, y0=np.asarray(y0), f=f)

# Create steprule
if atol is None or rtol is None:
raise ValueError(
)
firststep = step if step is not None else stepsize.propose_firststep(ivp)
else:
steprule = stepsize.ConstantSteps(step)

if method not in METHODS.keys():
msg1 = f"Parameter method='{method}' is not supported. "
msg2 = f"Possible values are {list(METHODS.keys())}."
errormsg = msg1 + msg2
raise ValueError(errormsg)
scipy_solver = METHODS[method]
wrapped_scipy_solver = perturbed.scipy_wrapper.WrappedScipyRungeKutta(
scipy_solver, steprule=steprule
)

if perturb not in PERTURBS.keys():
msg1 = f"Parameter perturb='{perturb}' is not supported. "
msg2 = f"Possible values are {list(PERTURBS.keys())}."
errormsg = msg1 + msg2
raise ValueError(errormsg)

perturbed_solver = PERTURBS[perturb](
rng=rng,
solver=wrapped_scipy_solver,
noise_scale=noise_scale,
)

return perturbed_solver.solve(ivp=ivp, stop_at=time_stops)
```