[RFC] Adding Riccati ODE Solver for possibly oscillating ODEs

Hello!

My name is Steve Bronder, I am a software engineer at the Flatiron Institute (Simons Foundation) working with Dr Fruzsina Agocs (Department of Computer Science, CU Boulder).

I would like to contribute a new ODE solver for the solution of second order, linear, homogeneous ordinary differential equations (ODEs) with potentially rapidly oscillating solutions to SciPy. The problems the proposed method is able to solve are initial value problems (IVPs) of the form

u’’(t) + 2\gamma(t)u’(t) + \omega^2(t)u(t) = 0, \quad t \in [t_0, t_1], \nonumber \\ u(t_0) = u_0, \\ u’(t_0) = u’_0. \nonumber

We built a header-only C++ library and a Python package called riccaticpp implementing the numerical method described in [1] for solving
these problems. We would like to integrate these methods into the scipy.integrate module.

The runtime of methods for oscillatory IVPs currently available in SciPy scales proportionally to \omega, the frequency of oscillations in the solution, which gets prohibitively slow for large \omega. Problems such as in the form above, however, frequently appear in physics simulations (e.g. in quantum mechanics, electric circuits, suspension systems, molecular dynamics, cosmological models), and are a computational bottleneck. The method we propose is efficient: in the worst case, it performs similarly to the high-order explicit methods in scipy.integrate.solve_ivp, but under certain conditions, its runtime is frequency- (\omega-)independent.

Giving researchers easy access to this general purpose solver for oscillatory ODEs via SciPy will allow them to explore more computationally complex models in their research and speed up existing implementations.

riccaticpp outperforms existing oscillatory solvers for examples such Bremer’s equation 237 below with a runtime that is faster by a a factor of 2 to 100. the graph below compares the runtimes of state-of-the-art solvers and riccaticpp on the test problem

u’’ + \lambda^2 q(t)u = 0, \quad t \in [-1, 1], \nonumber \\ u(-1) = 0, \\ u’(-1) = \lambda, \nonumber

with q(t) = \sqrt{1 - t^2\cos (3 t)}. The solution of this test problem oscillates with a time-dependent frequency \approx \lambda, chosen because some of the methods under comparison can only handle a less general class of IVPs whose solution is always oscillatory. riccaticpp can handle ``turning points’’ in the solution, i.e. regions of no or slow oscillation.

The top two graphs show the median run time for epsilon equal to 1e-12 and 1e-6 while the bottom two graphs show the error of the last point relative to Bremers solutions. The blue area in the graph of errors is the best possible lower bound for error calculated by Bremer. Because the results of each algorithm are compared relative to Bremer’s values, the algorithms may report smaller values than the actual possible error, but should be considered bounded by the blue area.

The current C++ build is based on cmake, but I would be happy to move things over to meson. Our only C++ dependency is the Eigen library. We use c++17, but the code could be made c++14 compliant if preferred.

The current Python API first builds an opaque block for holding precomputed quantities for a given problem that may then be reused in repeated solves (e.g. a grid search). It also holds an arena allocator whose memory is reused between and within solves. Below is an example of users calling the package for solving Bremers equation. We would be happy to modify this API to make it look more similar to those of the current SciPy solvers where possible.

import pyriccaticpp as ric
import numpy as np
import scipy.special as sp
epss, epshs, init_step = [1e-12, 1e-6], [1e-13, 1e-9], [35, 20], [0.1, 0.01, 1e-3]
for lambda in np.logspace(1, 7, num=7):
  omega = lambda t: lambda * np.sqrt(1 - t**2 * np.cos(3 * t))
  gamma = lambda t: np.zeros_like(t)
  info = ric.Init(omega, gamma, 8, 32, 32, 32)
  t0 = 1e0
  tn = 1e6
  u0 = complex(sp.airy(-xi)[0] + 1j * sp.airy(-xi)[2])
  du0 = complex(-sp.airy(-xi)[1] - 1j * sp.airy(-xi)[3])
  Neval = int(1e2)
  t_eval = np.linspace(xf, xi, Neval)
  for eps, epsh, in_step in zip(epss, epshs, init_step):
    xs, ys, dys, ss, ps, stypes, yeval, dyeval = ric.evolve(
        info, t0, tn, u0, du0, eps, epsh, init_stepsize=in_step, x_eval=t_eval
    )

Please let us know if integrating this method into SciPy would be of interest and possible. If so, we would love to discuss the integration process and requirements. Thank you for your time!

1 Like

Thanks for the detailed write-up @SteveBronder !! I don’t have the expertise to comment much of substance here, but to get the ball rolling:

This is not a worry, we are already using c++17 :+1:

1 Like

Thanks @lucascolley! Is this the right venue for this proposal or should I post it on the scipy issue tracker?

EDIT: Reading the scipy feature contributor docs again this seems to be the right place!

1 Like

Hi Steve,

This is very interesting and potentially in scope for scipy! The paper is very new however, so it’d be helpful to see some more comparison with existing approaches.

Two technical points, too:

  1. Can this be implemented as a specific solver within the solve_ivp framework?

  2. The eigen dependency I’m not sure we want to introduce; I suppose you could replace it with direct LAPACK calls?

Cheers,

Evgeni

1 Like

This is very interesting and potentially in scope for scipy! The paper is very new however, so it’d be helpful to see some more comparison with existing approaches.

Wonderful! More comparisons are reasonable, I’m speaking to Fruzsina now about this and will report back. We can run some comparisons using scipys current suite of methods

  1. Can this be implemented as a specific solver within the solve_ivp framework?

Possibly, but there are a few small differences we would need to sort out

Right now the code requires a python function for \gamma(t) and \omega(t), but in solve_ivp it only takes one fun.

Could the argument fun be allowed to be either a tuple of functions or a function returning a tuple? Otherwise we could have \gamma(t) be the argument fun and have another argument in options for passing \omega(t).

I’d also need to add the event and args arguments, but I think those are a good idea to do anyway.

  1. The eigen dependency I’m not sure we want to introduce; I suppose you could replace it with direct LAPACK calls?

It would be nice to be able to keep using Eigen, but if there is nothing else using Eigen in scipy then I agree that is a big dependency to take in. It will take a bit of work but I think I can get this all working with just LAPACK calls. But I think I’d only want to do that if we all agree the method is a good idea to bring into scipy as it would be a pretty big change.