A few days ago I wrote some code, meant to be run on an Arduino, for approximating integrals. This required me to delve deep into nearly-forgotten knowledge about numerical methods; I figured that while I still had this fresh on my mind I’d take the time to share a basic tutorial here.
Let’s suppose that we have the integral
The naive approach, which is typically taught in introductory calculus classes, is to pick some step size and divide the interval into subintervals of size , giving us points
Then, we can approximate the integral within each subinterval with something like the midpoint rule,
or the trapezoidal rule,
Here’s a simple implementation of the midpoint rule:
// integrate function f on [a, b] with midpoint rule and step size h
template <typename Function>
double integrate_midpoint(Function&& f, double a, double b, double h) {
double result = 0;
double x0 = a;
while (x0 < b) {
// set subinterval endpoint
double x1 = x0 + h;
if (x1 > b) {
x1 = b;
h = b - x0;
}
// add midpoint, scaled by h
result += h * f((x0 + x1) / 2.0);
// move forward
x0 = x1;
}
return result;
}
Each of these methods will have some amount of error associated with them. That error will be smaller if we use a smaller subinterval size , but it would be nice to be able to be able to bound the error more directly.
Let’s suppose that we have some level of error, , that we can tolerate. That is, if is our approximation (computed with something like the midpoint or trapezoidal rule), then we want to ensure that
Here’s one algorithm we could use to come up with a suitable approximation.
The tricky part here is in step 2: how do we determine the error of a given approximation?
There is one more modification we can make here: depending on how volatile the function is in different parts of its domain, it might make sense to use different step sizes in different parts of the interval . From here on, we’ll use the notation to denote the size of the nth subinterval (), and we’ll call the integral over that subinterval:
Let’s see if we can get an estimate for the error in a given subinterval, starting with the trapezoidal rule. We want to figure out the error introduced in the nth block,
The trick is to take the Taylor series approximation of this, which gives us
This tells us how quickly the error will diminish as we decrease the step size (it scales with ), but we still don’t have an exact error estimate, since this depends on the value of .
However, if we do the same thing with the midpoint approximation:
we get
The thing to notice is that
This means that we can use the difference between the trapezoidal and midpoint approximations as an estimate for the error of the midpoint approximation! Specifically, is a third-order approximation for the error .
We can also use this to figure out how big of a step size we should use in order to get a desired amount of error.
The error we have is , and if we want to get some target error , we should instead choose so that .
This gives us
Therefore, we can choose the new step size as
This gives us a method for adaptive quadrature with global error :
Here’s a basic implementation in code:
// integrate function f on [a, b] with midpoint rule and adaptive step size
// error param is desired global error bound
template <typename Function>
double integrate_adaptive(Function&& f, double a, double b, double error) {
double result = 0;
double x0 = a;
double h = b - a;
while (x0 < b) {
// set subinterval endpoint
double x1 = x0 + h;
if (x1 > b) {
x1 = b;
h = b - x0;
}
// calculate midpoint and trapezoidal approximations
double mn = h * f((x0 + x1) / 2.0);
double tn = h * (f(x0) + f(x1)) / 2.0;
// compute estimated error of mn, compare to target
double error_target = error * h / (b - a);
double error_est = abs(tn - mn) / 3.0;
if (error_est < error_target) {
// accept approximation and move on
result += mn;
x0 = x1;
}
// choose new step size
h *= 0.9 * pow(error_target / error_est, 1.0 / 3.0);
}
return result;
}
There are still a lot of clever things we can do to improve this (e.g., my Arduino code also dynamically adjusts the order of approximation), but for a quick and dirty approach this does a good job.