The adaptive integrator#
The taylor_adaptive
class provides an easy-to-use interface to heyoka.py’s
main capabilities. Objects of this class can be constructed from a system
of ODEs and a set of initial conditions (plus a number of optional configuration parameters
with - hopefully - sensible defaults). Methods are provided to
propagate in time the state of the system, either step-by-step or by specifying
time limits.
Let’s see how we can use taylor_adaptive
to integrate the ODE
system of the simple pendulum,
with initial conditions
Construction#
import heyoka as hy
# Create the symbolic variables x and v.
x, v = hy.make_vars("x", "v")
# Create the integrator object.
ta = hy.taylor_adaptive(
# Definition of the ODE system:
# x' = v
# v' = -9.8 * sin(x)
sys = [(x, v),
(v, -9.8 * hy.sin(x))],
# Initial conditions for x and v.
state = [0.05, 0.025])
ta
C++ datatype : double
Tolerance : 2.220446049250313e-16
High accuracy : false
Compact mode : false
Taylor order : 20
Dimension : 2
Time : 0
State : [0.05, 0.025]
After creating the symbolic variables x
and v
, we
construct an instance of taylor_adaptive
called ta
.
By default, taylor_adaptive
operates using double-precision arithmetic. As (mandatory) construction arguments, we pass in the system of differential equations and a set
of initial conditions for x
and v
respectively.
By default, the error tolerance of an adaptive integrator is set to the
machine epsilon, which, for double precision, is \(\sim 2.2\times10^{-16}\).
From this value, heyoka.py deduces an optimal Taylor order of 20, as indicated
by the screen output. taylor_adaptive
manages its own copy of the state vector and the time variable.
Both the state vector and the time variable are updated automatically by the timestepping
methods. Note also how, by default, the time variable is initially set to zero.
Single timestep#
Let’s now try to perform a single integration timestep:
# Perform a single step.
oc, h = ta.step()
# Print the outcome flag and the timestep used.
print("Outcome : {}".format(oc))
print("Timestep: {}".format(h))
Outcome : taylor_outcome.success
Timestep: 0.21605277478009474
First, we invoke the step()
method, which returns a pair of values.
The first value is a status flag indicating the outcome of the integration timestep,
while the second value is the step size that was selected by heyoka.py in order
to respect the desired error tolerance.
Let’s also print again the integrator object to screen in order to inspect how state and time have changed:
ta
C++ datatype : double
Tolerance : 2.220446049250313e-16
High accuracy : false
Compact mode : false
Taylor order : 20
Dimension : 2
Time : 0.21605277478009474
State : [0.04399644836992638, -0.07844245547068798]
It is also possible to perform a single timestep backward in time
via the step_backward()
method:
# Perform a step backward.
oc, h = ta.step_backward()
# Print the outcome flag and the timestep used.
print("Outcome : {}".format(oc))
print("Timestep: {}".format(h))
Outcome : taylor_outcome.success
Timestep: -0.21312300047513288
The step()
method can also be called with an argument representing
the maximum step size max_delta_t
: if the adaptive timestep
selected by heyoka.py is larger (in absolute value) than max_delta_t
,
then the timestep will be clamped to max_delta_t
:
# Perform a step forward in time, clamping
# the timestep size to 0.01.
oc, h = ta.step(max_delta_t = 0.01)
# Print the outcome flag and the timestep used.
print("Outcome : {}".format(oc))
print("Timestep: {}\n".format(h))
# Perform a step backward in time, clamping
# the timestep size to 0.02.
oc, h = ta.step(max_delta_t = -0.02)
# Print the outcome flag and the timestep used.
print("Outcome : {}".format(oc))
print("Timestep: {}".format(h))
Outcome : taylor_outcome.time_limit
Timestep: 0.01
Outcome : taylor_outcome.time_limit
Timestep: -0.02
Note that the integration outcome is now time_limit
, instead of success
.
Before moving on, we need to point out an important caveat when using the single step functions:
Warning
If the exact solution of the ODE system can be expressed as a polynomial function
of time, the automatic timestep deduction algorithm may return a timestep of infinity.
This is the case, for instance, when integrating the rectilinear motion of a free
particle or the constant-gravity free-fall equation. In such cases, the step functions
should be called with a finite max_delta_t
argument, in order to clamp the timestep
to a finite value.
Note that the propagate_*()
functions (described below)
are not affected by this issue.
Accessing state and time#
It is possible to read from and write to both the time variable and the state vector:
# Print the current time.
print("Current time : {}".format(ta.time))
# Print out the current state vector.
print("Current state vector: {}\n".format(ta.state))
# Reset the time and state to the initial values.
ta.time = 0.
ta.state[:] = [0.05, 0.025]
# Print them again.
print("Current time : {}".format(ta.time))
print("Current state vector: {}".format(ta.state))
Current time : -0.007070225695038143
Current state vector: [0.04981102 0.02845657]
Current time : 0.0
Current state vector: [0.05 0.025]
Note that the time is stored as a scalar value, while the state is stored as a NumPy array.
Because of technical reasons related to the management of the lifetime of arrays when interacting with the underlying heyoka C++ library, it is not possible to directly set the state via the syntax
>>> ta.state = [0.05, 0.025] # Won't work!
Thus, the array assignment syntax ta.state[:] = ...
must be used instead. Similarly, it is also possible to set directly the values of the components of the array:
ta.state[0] = 0.05
ta.state[1] = 0.025
Time-limited propagation#
In addition to the step-by-step integration methods,
taylor_adaptive
also provides methods to propagate
the state of the system for a specified amount of time.
These methods are called propagate_for()
and
propagate_until()
: the former integrates
the system for a specified amount of time, the latter
propagates the state up to a specified epoch.
# Propagate for 5 time units.
status, min_h, max_h, nsteps, _, _ = ta.propagate_for(delta_t = 5.)
print("Outcome : {}".format(status))
print("Min. timestep: {}".format(min_h))
print("Max. timestep: {}".format(max_h))
print("Num. of steps: {}".format(nsteps))
print("Current time : {}\n".format(ta.time))
# Propagate until t = 20.
status, min_h, max_h, nsteps, _, _ = ta.propagate_until(t = 20.)
print("Outcome : {}".format(status))
print("Min. timestep: {}".format(min_h))
print("Max. timestep: {}".format(max_h))
print("Num. of steps: {}".format(nsteps))
print("Current time : {}".format(ta.time))
Outcome : taylor_outcome.time_limit
Min. timestep: 0.20213323505293765
Max. timestep: 0.21813566576411725
Num. of steps: 24
Current time : 5.0
Outcome : taylor_outcome.time_limit
Min. timestep: 0.20212172864807665
Max. timestep: 0.2181392923080563
Num. of steps: 72
Current time : 20.0
The time-limited propagation methods return a tuple of 6 values, which represent, respectively:
the outcome of the integration (which will always be
time_limit
, unless error conditions arise),the minimum and maximum integration timesteps that were used in the propagation,
the total number of steps that were taken,
the continuous output function object, if requested (off by default),
the step callback (see below for an explanation). If no callback has been provided,
None
is returned.
The time-limited propagation methods can be used to propagate both forward and backward in time:
# Propagate back to t = 0.
status, min_h, max_h, nsteps, _, _ = ta.propagate_until(t = 0.)
print("Outcome : {}".format(status))
print("Min. timestep: {}".format(min_h))
print("Max. timestep: {}".format(max_h))
print("Num. of steps: {}".format(nsteps))
print("Current time : {}\n".format(ta.time))
print(ta)
Outcome : taylor_outcome.time_limit
Min. timestep: 0.20207792808238695
Max. timestep: 0.21818982934810394
Num. of steps: 97
Current time : 0.0
C++ datatype : double
Tolerance : 2.220446049250313e-16
High accuracy : false
Compact mode : false
Taylor order : 20
Dimension : 2
Time : 0
State : [0.050000000000000044, 0.02499999999999999]
Note also that the time-limited propagation methods will stop
integrating if a non-finite value is detected in the state vector
at the end of the timestep. In such case, the outcome of the
integration will be err_nf_state
.
The propagate_for()
and propagate_until()
methods
can be invoked with additional optional keyword arguments:
max_delta_t
: similarly to thestep()
function, this value represents the maximum timestep size in absolute value;callback
: this is a callable which will be invoked at the end of each timestep, with the integrator object as only argument. This is known as a step callback. If the callback returnsTrue
then the integration will continue after the invocation of the callback, otherwise the integration will be interrupted;c_output
: a boolean flag that enables continuous output.
# Propagate to t = .5 using a max_delta_t and
# providing a callback that prints the current time.
# The callback.
def cb(ta):
print("Current time: {}".format(ta.time))
return True
ta.propagate_until(t = .5, max_delta_t = .1, callback = cb);
Current time: 0.1
Current time: 0.2
Current time: 0.30000000000000004
Current time: 0.4
Current time: 0.5
Optionally, callbacks can implement a pre_hook()
method that will be invoked
once before the first step is taken by the propagate_for()
and propagate_until()
methods:
# Callback with pre_hook().
class cb:
def __call__(self, ta):
print("Current time: {}".format(ta.time))
return True
def pre_hook(self, ta):
print("pre_hook() invoked!")
ta.propagate_until(t = 1.5, max_delta_t = .1, callback = cb());
pre_hook() invoked!
Current time: 0.6
Current time: 0.7
Current time: 0.8
Current time: 0.9
Current time: 1.0
Current time: 1.1
Current time: 1.2
Current time: 1.3
Current time: 1.4000000000000001
Current time: 1.5
Step callbacks are returned as the sixth member of the return tuple of
the propagate_for()
and propagate_until()
methods.
Propagation over a time grid#
Another way of propagating the state of a system in a taylor_adaptive
integrator is over a time grid. In this mode, the integrator
uses dense output to compute the state of the system
over a grid of time coordinates provided by the user. If the grid is denser
than the typical timestep size, this can be noticeably more efficient than
repeatedly calling propagate_until()
on the grid points, because
propagating the system state via dense output is much faster than taking
a full integration step.
Let’s see a simple usage example:
# Reset the time and state to the initial values.
ta.time = 0.
ta.state[:] = [0.05, 0.025]
# Propagate over a time grid from 0 to 1
# at regular intervals.
out = ta.propagate_grid(grid = [0., .1, .2, .3, .4, .5, .6, .7, .8, .9, 1.])
out
(<taylor_outcome.time_limit: -4294967299>,
0.2021425243240425,
0.21605277478009474,
5,
None,
array([[ 0.05 , 0.025 ],
[ 0.05003035, -0.024398 ],
[ 0.04519961, -0.07142727],
[ 0.03597685, -0.11152037],
[ 0.02325783, -0.14078016],
[ 0.00827833, -0.15635952],
[-0.00750582, -0.15674117],
[-0.02256041, -0.14188793],
[-0.03542229, -0.11324639],
[-0.04484178, -0.07360369],
[-0.04990399, -0.02681336]]))
propagate_grid()
takes in input a grid of time points,
and returns a tuple of 6 values. The first 4 values are the same
as in the other propagate_*()
functions:
the outcome of the integration,
the minimum and maximum integration timesteps that were used in the propagation,
the total number of steps that were taken.
The fifth value returned by propagate_grid()
is the step callback, if provided by the user (see below). Otherwise, None
is returned.
The sixth value returned by propagate_grid()
is a 2D array containing
the state of the system at the time points in the grid:
# Print the state at t = 0.4 (index 4 in the time grid).
print("State at t = 0.4: {}".format(out[5][4]))
State at t = 0.4: [ 0.02325783 -0.14078016]
As you can see from the screen output above (where we printed the out
variable), the use of propagate_grid()
resulted in 5 integration timesteps being taken. Had we used propagate_until()
, we would have needed 10 integration timesteps to obtain the same result.
There are a few requirements on the time values in the grid:
they must all be finite,
they must be ordered monotonically,
the first value in the grid must be equal to the current time of the integrator.
Changed in version 4.0.0: The requirement on the first value of the time grid.
The propagate_grid()
method
can be invoked with additional optional keyword arguments:
max_delta_t
: similarly to thestep()
function, this value represents the maximum timestep size in absolute value;callback
: this is a callable which will be invoked at the end of each timestep, with the integrator object as only argument. This is known as a step callback. If the callback returnsTrue
then the integration will continue after the invocation of the callback, otherwise the integration will be interrupted.
# Propagate over a grid using a max_delta_t and
# providing a callback that prints the current time.
# The callback.
def cb(ta):
print("Current time: {}".format(ta.time))
return True
ta.propagate_until(1.1)
ta.propagate_grid(grid = [1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.],
max_delta_t = .1, callback = cb);
Current time: 1.2000000000000002
Current time: 1.3
Current time: 1.4000000000000001
Current time: 1.5
Current time: 1.6
Current time: 1.7000000000000002
Current time: 1.8
Current time: 1.9000000000000001
Current time: 2.0
Optionally, callbacks can implement a pre_hook()
method that will be invoked
once before the first step is taken by the propagate_grid()
method:
# Callback with pre_hook().
class cb:
def __call__(self, ta):
print("Current time: {}".format(ta.time))
return True
def pre_hook(self, ta):
print("pre_hook() invoked!")
ta.propagate_until(1.1)
ta.propagate_grid(grid = [1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.],
max_delta_t = .1, callback = cb());
pre_hook() invoked!
Current time: 1.2000000000000002
Current time: 1.3
Current time: 1.4000000000000001
Current time: 1.5
Current time: 1.6
Current time: 1.7000000000000002
Current time: 1.8
Current time: 1.9000000000000001
Current time: 2.0
Step callbacks are returned as the fifth member of the return tuple of
the propagate_grid()
method.