Dense & continuous output#
Added in version 0.4.0.
One of the peculiar features of Taylor’s method is that it directly provides, via the Taylor series (4), dense (or continuous) output. That is, the Taylor series built by the integrator at each timestep can be used to compute the solution of the ODE system at any time within the timestep (and not only at the endpoint) via polynomial evaluation.
Because the construction of the Taylor series is part of the timestepping algorithm, support for dense output comes at essentially no extra cost. Additionally, because the dense output is computed via the Taylor series of the solution of the ODE system, its accuracy is guaranteed to respect the error tolerance set in the integrator.
Dense output can be used either from a low-level API, which gives direct access to the coefficients of the Taylor polynomials of the solution within a timestep, or from a higher-level API, which facilitates the common use case of using the coefficients of the Taylor polynomials to compute the continuous extension of the solution. If you are interested only in the latter, you can skip the next sections and jump directly to the continuous output section.
Dense output for the step()
functions#
In order to illustrate how to use dense output in heyoka, we will keep things simple and go back to the simple pendulum:
// Create the symbolic variables x and v.
auto [x, v] = make_vars("x", "v");
// Create the integrator object
// in double precision.
auto ta = taylor_adaptive<double>{// Definition of the ODE system:
// x' = v
// v' = -9.8 * sin(x)
{prime(x) = v, prime(v) = -9.8 * sin(x)},
// Initial conditions
// for x and v.
{0.05, 0.025}};
Enabling dense output for the step()
functions in heyoka is a two-step process.
The first step is to invoke one of the step()
functions
passing an extra boolean parameter set to true
:
// Integrate for a single timestep, and store
// the Taylor coefficients in the integrator.
ta.step(true);
The extra true
function argument instructs the integrator
to record into an internal array the list of Taylor series
coefficients that were generated by the timestepping algorithm.
We can fetch a reference to the list of Taylor coefficients
via the get_tc()
member function:
// Fetch the Taylor coefficients of order 0
// for x and v.
std::cout << "TC of order 0 for x: " << ta.get_tc()[0] << '\n';
std::cout << "TC of order 0 for v: " << ta.get_tc()[ta.get_order() + 1u] << '\n';
The Taylor coefficients are stored in row-major order,
each row referring to a different state variable. The number
of columns is the Taylor order of the integrator plus one.
Thus, index 0 in the array refers to the zero-order coefficient
for the x
variable, while index ta.get_order() + 1
refers
to the zero-order coefficient for the v
variable:
TC of order 0 for x: 0.05
TC of order 0 for v: 0.025
Indeed, the zero-order Taylor coefficients for the state variables are nothing but the initial conditions at the beginning of the timestep that was just taken.
Important
This last point is important and needs to be stressed again: the list of Taylor coefficients always refers to the last step taken and not to the next step that the integrator might take.
We are now ready to ask the integrator to compute the value of the solution at some arbitrary time. Let’s pick \(t = 0.1\), which is about halfway through the timestep that was just taken:
// Compute and print the dense output at t = 0.1.
auto &d_out = ta.update_d_output(0.1);
std::cout << "x(0.1) = " << d_out[0] << '\n';
std::cout << "y(0.1) = " << d_out[1] << '\n';
x(0.1) = 0.0500303
y(0.1) = -0.024398
The update_d_output()
member function takes in input an absolute time coordinate
and returns a reference to an internal array that will contain the state of the system
at the specified time coordinate, as computed by the evaluation of the Taylor series.
update_d_output()
can also be called with a time coordinate relative to the current
time by passing true
as a second function argument.
Let’s now ask for the dense output at the very end of the timestep that was just taken, and let’s compare it to the current state vector:
// Compute the dense output at the end of the
// previous timestep and compare it to the current
// state vector.
ta.update_d_output(ta.get_time());
const auto &st = ta.get_state();
std::cout << "x rel. difference: " << (d_out[0] - st[0]) / st[0] << '\n';
std::cout << "v rel. difference: " << (d_out[1] - st[1]) / st[1] << '\n';
x rel. difference: 0
v rel. difference: -0
That is, as expected, the dense output at the end of the previous timestep matches the current state of the system to machine precision.
Before concluding, we need to highlight a couple of caveats regarding the use of dense output.
Important
First, it is the user’s responsibility to ensure that the array of Taylor
coefficients contains up-to-date values. In other words, the user needs to remember
to invoke the step()
functions with the extra boolean argument set to true
before invoking update_d_output()
.
Failure to do so will result in update_d_output()
producing incorrect values.
Important
Second, the accuracy of dense output is guaranteed to match the integrator’s
accuracy only if the time coordinate falls within the last step taken. Note that heyoka will
not prevent the invocation of update_d_output()
with time coordinates outside the
guaranteed accuracy range - it is the user’s responsibility to be aware
that doing so will produce results whose accuracy does not match the integrator’s
error tolerance.
Dense output for the propagate_*()
functions#
Added in version 0.8.0.
Dense output can be enabled also for the time-limited propagation functions
propagate_for()
and propagate_until()
via the boolean keyword argument
write_tc
.
When write_tc
is set to true
, the propagate_*()
functions
will internally invoke the step()
function with the optional boolean
flag set to true
, so that at the end of each timestep the Taylor coefficients
will be available. The Taylor coefficients can be used, e.g., inside the
step callback that can be passed to the propagate_*()
functions.
Note that propagate_grid()
always unconditionally writes the Taylor coefficients
at the end of each timestep, and thus using the write_tc
argument is not necessary.
Continuous output#
Added in version 0.16.0.
The propagate_for()
and propagate_until()
functions can optionally return
a function object providing continuous output in the integration interval.
That is, this function object can be used to compute the solution at any time
within the time interval covered by propagate_for/until()
.
Let us see a concrete example of continuous_output
in action:
// Reset time and state.
ta.get_state_data()[0] = 0.05;
ta.get_state_data()[1] = 0.025;
ta.set_time(0);
// Integrate up to t = 10, and request continuous output.
auto c_out = std::get<4>(ta.propagate_until(10., kw::c_output = true));
Continuous output can be requested via the c_output
boolean keyword flag, and it is stored
as the fifth element of the tuple returned by propagate_for/until()
. The continuous_output
function object
is wrapped in a std::optional
(so that when continuous output is not requested, the
std::optional
will be empty).
Let us try to print c_out
to screen:
// Print to screen.
std::cout << *c_out << '\n';
Direction : forward
Time range: [0, 10)
N of steps: 48
The screen output informs us that the c_out
object is capable of providing continuous output
in the \(\left[ 0, 10 \right)\) time interval, and that 48 steps were taken
during the integration.
Let us compute and print to screen the state of the system at a few time coordinates:
// Compute and print to screen the system state
// at different time coordinates.
for (auto tm : {0., 1.5, 4.3, 6.7, 8.9, 10.}) {
// Compute the state at tm.
(*c_out)(tm);
// Print it out:
std::cout << "time=" << tm << ", x=" << c_out->get_output()[0] //
<< ", v=" << c_out->get_output()[1] << '\n';
}
time=0, x=0.05, v=0.025
time=1.5, x=-0.0088572, v=0.156048
time=4.3, x=0.0375906, v=-0.106177
time=6.7, x=-0.0193535, v=-0.146456
time=8.9, x=-0.0424699, v=-0.0862923
time=10, x=0.0487397, v=0.0429423
The call operator of c_out
takes a time value tm
as input, and computes and writes to
an internal buffer the value of the state vector at tm
. The get_output()
member function
returns a reference to the internal buffer containing the state of the system at tm
.
As we can see from the screen output, the state vector at \(t = 0\) corresponds to the initial conditions. The state vector at \(t=10\) (i.e., at the end of the integration interval) corresponds to the current state of the integrator object.
Continuous output is somewhat similar to propagate_grid()
, in the sense that both allow to compute
the value of the solution at arbitrary time points. propagate_grid()
is computationally more efficient,
but it requires to specify up-front the list of times at which the solution should be computed.
Continuous output, on the other hand, is not bound to a predetermined time grid, and it can thus be helpful
if time coordinates of interest can be identified only after the solution has been computed.
Before concluding, we need to highlight a couple of caveats regarding the
use of continuous_output
.
Important
The continuous_output
function object stores internally the time coordinate
and Taylor coefficients at the end of each step taken during the integration interval.
This means that the memory usage of a continuous_output
object scales linearly
with the number of timesteps taken during the integration interval. Thus, for a sufficiently
long integration interval, the continuous_output
object might end up exhausting
the available memory.
Important
Like for dense output, the accuracy of continuous_output
is guaranteed to match the integrator’s
accuracy only if the time coordinate falls within the integration interval. Note that heyoka will
not prevent the use of a continuous_output
object outside the
guaranteed accuracy range - it is the user’s responsibility to be aware
that doing so will produce results whose accuracy does not match the integrator’s
error tolerance.
Note that, like the other main classes in heyoka, continuous_output
supports
serialisation.
Full code listing#
#include <iostream>
#include <heyoka/heyoka.hpp>
using namespace heyoka;
int main()
{
// Create the symbolic variables x and v.
auto [x, v] = make_vars("x", "v");
// Create the integrator object
// in double precision.
auto ta = taylor_adaptive<double>{// Definition of the ODE system:
// x' = v
// v' = -9.8 * sin(x)
{prime(x) = v, prime(v) = -9.8 * sin(x)},
// Initial conditions
// for x and v.
{0.05, 0.025}};
// Integrate for a single timestep, and store
// the Taylor coefficients in the integrator.
ta.step(true);
// Fetch the Taylor coefficients of order 0
// for x and v.
std::cout << "TC of order 0 for x: " << ta.get_tc()[0] << '\n';
std::cout << "TC of order 0 for v: " << ta.get_tc()[ta.get_order() + 1u] << '\n';
// Compute and print the dense output at t = 0.1.
auto &d_out = ta.update_d_output(0.1);
std::cout << "x(0.1) = " << d_out[0] << '\n';
std::cout << "y(0.1) = " << d_out[1] << '\n';
// Compute the dense output at the end of the
// previous timestep and compare it to the current
// state vector.
ta.update_d_output(ta.get_time());
const auto &st = ta.get_state();
std::cout << "x rel. difference: " << (d_out[0] - st[0]) / st[0] << '\n';
std::cout << "v rel. difference: " << (d_out[1] - st[1]) / st[1] << '\n';
// Reset time and state.
ta.get_state_data()[0] = 0.05;
ta.get_state_data()[1] = 0.025;
ta.set_time(0);
// Integrate up to t = 10, and request continuous output.
auto c_out = std::get<4>(ta.propagate_until(10., kw::c_output = true));
// Print to screen.
std::cout << *c_out << '\n';
// Compute and print to screen the system state
// at different time coordinates.
for (auto tm : {0., 1.5, 4.3, 6.7, 8.9, 10.}) {
// Compute the state at tm.
(*c_out)(tm);
// Print it out:
std::cout << "time=" << tm << ", x=" << c_out->get_output()[0] //
<< ", v=" << c_out->get_output()[1] << '\n';
}
}