Skip to content

Commit

Permalink
[wpimath] Add time-varying RKDP (wpilibsuite#7362)
Browse files Browse the repository at this point in the history
This makes the ground truth for the Taylor series AQ discretization more
accurate.
  • Loading branch information
calcmogul authored Nov 8, 2024
1 parent 01f85ab commit 661bae5
Show file tree
Hide file tree
Showing 10 changed files with 369 additions and 174 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,32 @@ public static <States extends Num> Matrix<States, N1> rk4(
return x.plus((k1.plus(k2.times(2.0)).plus(k3.times(2.0)).plus(k4)).times(h / 6.0));
}

/**
* Performs 4th order Runge-Kutta integration of dx/dt = f(t, y) for dt.
*
* @param <Rows> Rows in y.
* @param <Cols> Columns in y.
* @param f The function to integrate. It must take two arguments t and y.
* @param t The initial value of t.
* @param y The initial value of y.
* @param dtSeconds The time over which to integrate.
* @return the integration of dx/dt = f(x) for dt.
*/
public static <Rows extends Num, Cols extends Num> Matrix<Rows, Cols> rk4(
BiFunction<Double, Matrix<Rows, Cols>, Matrix<Rows, Cols>> f,
double t,
Matrix<Rows, Cols> y,
double dtSeconds) {
final var h = dtSeconds;

Matrix<Rows, Cols> k1 = f.apply(t, y);
Matrix<Rows, Cols> k2 = f.apply(t + dtSeconds * 0.5, y.plus(k1.times(h * 0.5)));
Matrix<Rows, Cols> k3 = f.apply(t + dtSeconds * 0.5, y.plus(k2.times(h * 0.5)));
Matrix<Rows, Cols> k4 = f.apply(t + dtSeconds, y.plus(k3.times(h)));

return y.plus((k1.plus(k2.times(2.0)).plus(k3.times(2.0)).plus(k4)).times(h / 6.0));
}

/**
* Performs adaptive Dormand-Prince integration of dx/dt = f(x, u) for dt. By default, the max
* error is 1e-6.
Expand Down Expand Up @@ -252,4 +278,132 @@ public static <States extends Num, Inputs extends Num> Matrix<States, N1> rkdp(

return x;
}

/**
* Performs adaptive Dormand-Prince integration of dx/dt = f(t, y) for dt.
*
* @param <Rows> Rows in y.
* @param <Cols> Columns in y.
* @param f The function to integrate. It must take two arguments t and y.
* @param t The initial value of t.
* @param y The initial value of y.
* @param dtSeconds The time over which to integrate.
* @param maxError The maximum acceptable truncation error. Usually a small number like 1e-6.
* @return the integration of dx/dt = f(x, u) for dt.
*/
@SuppressWarnings("overloads")
public static <Rows extends Num, Cols extends Num> Matrix<Rows, Cols> rkdp(
BiFunction<Double, Matrix<Rows, Cols>, Matrix<Rows, Cols>> f,
double t,
Matrix<Rows, Cols> y,
double dtSeconds,
double maxError) {
// See https://en.wikipedia.org/wiki/Dormand%E2%80%93Prince_method for the
// Butcher tableau the following arrays came from.

// final double[6][6]
final double[][] A = {
{1.0 / 5.0},
{3.0 / 40.0, 9.0 / 40.0},
{44.0 / 45.0, -56.0 / 15.0, 32.0 / 9.0},
{19372.0 / 6561.0, -25360.0 / 2187.0, 64448.0 / 6561.0, -212.0 / 729.0},
{9017.0 / 3168.0, -355.0 / 33.0, 46732.0 / 5247.0, 49.0 / 176.0, -5103.0 / 18656.0},
{35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0, 11.0 / 84.0}
};

// final double[7]
final double[] b1 = {
35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0, 11.0 / 84.0, 0.0
};

// final double[7]
final double[] b2 = {
5179.0 / 57600.0,
0.0,
7571.0 / 16695.0,
393.0 / 640.0,
-92097.0 / 339200.0,
187.0 / 2100.0,
1.0 / 40.0
};

// final double[6]
final double[] c = {1.0 / 5.0, 3.0 / 10.0, 4.0 / 5.0, 8.0 / 9.0, 1.0, 1.0};

Matrix<Rows, Cols> newY;
double truncationError;

double dtElapsed = 0.0;
double h = dtSeconds;

// Loop until we've gotten to our desired dt
while (dtElapsed < dtSeconds) {
do {
// Only allow us to advance up to the dt remaining
h = Math.min(h, dtSeconds - dtElapsed);

var k1 = f.apply(t, y);
var k2 = f.apply(t + h * c[0], y.plus(k1.times(A[0][0]).times(h)));
var k3 = f.apply(t + h * c[1], y.plus(k1.times(A[1][0]).plus(k2.times(A[1][1])).times(h)));
var k4 =
f.apply(
t + h * c[2],
y.plus(k1.times(A[2][0]).plus(k2.times(A[2][1])).plus(k3.times(A[2][2])).times(h)));
var k5 =
f.apply(
t + h * c[3],
y.plus(
k1.times(A[3][0])
.plus(k2.times(A[3][1]))
.plus(k3.times(A[3][2]))
.plus(k4.times(A[3][3]))
.times(h)));
var k6 =
f.apply(
t + h * c[4],
y.plus(
k1.times(A[4][0])
.plus(k2.times(A[4][1]))
.plus(k3.times(A[4][2]))
.plus(k4.times(A[4][3]))
.plus(k5.times(A[4][4]))
.times(h)));

// Since the final row of A and the array b1 have the same coefficients
// and k7 has no effect on newY, we can reuse the calculation.
newY =
y.plus(
k1.times(A[5][0])
.plus(k2.times(A[5][1]))
.plus(k3.times(A[5][2]))
.plus(k4.times(A[5][3]))
.plus(k5.times(A[5][4]))
.plus(k6.times(A[5][5]))
.times(h));
var k7 = f.apply(t + h * c[5], newY);

truncationError =
(k1.times(b1[0] - b2[0])
.plus(k2.times(b1[1] - b2[1]))
.plus(k3.times(b1[2] - b2[2]))
.plus(k4.times(b1[3] - b2[3]))
.plus(k5.times(b1[4] - b2[4]))
.plus(k6.times(b1[5] - b2[5]))
.plus(k7.times(b1[6] - b2[6]))
.times(h))
.normF();

if (truncationError == 0.0) {
h = dtSeconds - dtElapsed;
} else {
h *= 0.9 * Math.pow(maxError / truncationError, 1.0 / 5.0);
}
} while (truncationError > maxError);

dtElapsed += h;
y = newY;
}

return y;
}
}
103 changes: 103 additions & 0 deletions wpimath/src/main/native/include/frc/system/NumericalIntegration.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@ T RK4(F&& f, T x, U u, units::second_t dt) {
return x + h / 6.0 * (k1 + 2.0 * k2 + 2.0 * k3 + k4);
}

/**
* Performs 4th order Runge-Kutta integration of dy/dt = f(t, y) for dt.
*
* @param f The function to integrate. It must take two arguments t and y.
* @param t The initial value of t.
* @param y The initial value of y.
* @param dt The time over which to integrate.
*/
template <typename F, typename T>
T RK4(F&& f, units::second_t t, T y, units::second_t dt) {
const auto h = dt.to<double>();

T k1 = f(t, y);
T k2 = f(t + dt * 0.5, y + h * k1 * 0.5);
T k3 = f(t + dt * 0.5, y + h * k2 * 0.5);
T k4 = f(t + dt, y + h * k3);

return y + h / 6.0 * (k1 + 2.0 * k2 + 2.0 * k3 + k4);
}

/**
* Performs adaptive Dormand-Prince integration of dx/dt = f(x, u) for dt.
*
Expand Down Expand Up @@ -134,4 +154,87 @@ T RKDP(F&& f, T x, U u, units::second_t dt, double maxError = 1e-6) {
return x;
}

/**
* Performs adaptive Dormand-Prince integration of dy/dt = f(t, y) for dt.
*
* @param f The function to integrate. It must take two arguments t and
* y.
* @param t The initial value of t.
* @param y The initial value of y.
* @param dt The time over which to integrate.
* @param maxError The maximum acceptable truncation error. Usually a small
* number like 1e-6.
*/
template <typename F, typename T>
T RKDP(F&& f, units::second_t t, T y, units::second_t dt,
double maxError = 1e-6) {
// See https://en.wikipedia.org/wiki/Dormand%E2%80%93Prince_method for the
// Butcher tableau the following arrays came from.

constexpr int kDim = 7;

// clang-format off
constexpr double A[kDim - 1][kDim - 1]{
{ 1.0 / 5.0},
{ 3.0 / 40.0, 9.0 / 40.0},
{ 44.0 / 45.0, -56.0 / 15.0, 32.0 / 9.0},
{19372.0 / 6561.0, -25360.0 / 2187.0, 64448.0 / 6561.0, -212.0 / 729.0},
{ 9017.0 / 3168.0, -355.0 / 33.0, 46732.0 / 5247.0, 49.0 / 176.0, -5103.0 / 18656.0},
{ 35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0, 11.0 / 84.0}};
// clang-format on

constexpr std::array<double, kDim> b1{
35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0,
11.0 / 84.0, 0.0};
constexpr std::array<double, kDim> b2{5179.0 / 57600.0, 0.0,
7571.0 / 16695.0, 393.0 / 640.0,
-92097.0 / 339200.0, 187.0 / 2100.0,
1.0 / 40.0};

constexpr std::array<double, kDim - 1> c{1.0 / 5.0, 3.0 / 10.0, 4.0 / 5.0,
8.0 / 9.0, 1.0, 1.0};

T newY;
double truncationError;

double dtElapsed = 0.0;
double h = dt.to<double>();

// Loop until we've gotten to our desired dt
while (dtElapsed < dt.to<double>()) {
do {
// Only allow us to advance up to the dt remaining
h = std::min(h, dt.to<double>() - dtElapsed);

// clang-format off
T k1 = f(t, y);
T k2 = f(t + units::second_t{h} * c[0], y + h * (A[0][0] * k1));
T k3 = f(t + units::second_t{h} * c[1], y + h * (A[1][0] * k1 + A[1][1] * k2));
T k4 = f(t + units::second_t{h} * c[2], y + h * (A[2][0] * k1 + A[2][1] * k2 + A[2][2] * k3));
T k5 = f(t + units::second_t{h} * c[3], y + h * (A[3][0] * k1 + A[3][1] * k2 + A[3][2] * k3 + A[3][3] * k4));
T k6 = f(t + units::second_t{h} * c[4], y + h * (A[4][0] * k1 + A[4][1] * k2 + A[4][2] * k3 + A[4][3] * k4 + A[4][4] * k5));
// clang-format on

// Since the final row of A and the array b1 have the same coefficients
// and k7 has no effect on newY, we can reuse the calculation.
newY = y + h * (A[5][0] * k1 + A[5][1] * k2 + A[5][2] * k3 +
A[5][3] * k4 + A[5][4] * k5 + A[5][5] * k6);
T k7 = f(t + units::second_t{h} * c[5], newY);

truncationError = (h * ((b1[0] - b2[0]) * k1 + (b1[1] - b2[1]) * k2 +
(b1[2] - b2[2]) * k3 + (b1[3] - b2[3]) * k4 +
(b1[4] - b2[4]) * k5 + (b1[5] - b2[5]) * k6 +
(b1[6] - b2[6]) * k7))
.norm();

h *= 0.9 * std::pow(maxError / truncationError, 1.0 / 5.0);
} while (truncationError > maxError);

dtElapsed += h;
y = newY;
}

return y;
}

} // namespace frc
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ void testDiscretizeSlowModelAQ() {
// Q_d = ∫ e^(Aτ) Q e^(Aᵀτ) dτ
// 0
final var discQIntegrated =
RungeKuttaTimeVarying.rungeKuttaTimeVarying(
NumericalIntegration.rk4(
(Double t, Matrix<N2, N2> x) ->
contA.times(t).exp().times(contQ).times(contA.transpose().times(t).exp()),
0.0,
Expand Down Expand Up @@ -104,7 +104,7 @@ void testDiscretizeFastModelAQ() {
// Q_d = ∫ e^(Aτ) Q e^(Aᵀτ) dτ
// 0
final var discQIntegrated =
RungeKuttaTimeVarying.rungeKuttaTimeVarying(
NumericalIntegration.rk4(
(Double t, Matrix<N2, N2> x) ->
contA.times(t).exp().times(contQ).times(contA.transpose().times(t).exp()),
0.0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import static org.junit.jupiter.api.Assertions.assertEquals;

import edu.wpi.first.math.MatBuilder;
import edu.wpi.first.math.Matrix;
import edu.wpi.first.math.Nat;
import edu.wpi.first.math.VecBuilder;
Expand All @@ -30,6 +31,28 @@ void testExponential() {
assertEquals(Math.exp(0.1) - Math.exp(0.0), y1.get(0, 0), 1e-3);
}

// Tests RK4 with a time varying solution. From
// http://www2.hawaii.edu/~jmcfatri/math407/RungeKuttaTest.html:
// x' = x (2/(eᵗ + 1) - 1)
//
// The true (analytical) solution is:
//
// x(t) = 12eᵗ/(eᵗ + 1)²
@Test
void testRK4TimeVarying() {
final var y0 = VecBuilder.fill(12.0 * Math.exp(5.0) / Math.pow(Math.exp(5.0) + 1.0, 2.0));

final var y1 =
NumericalIntegration.rk4(
(Double t, Matrix<N1, N1> y) ->
MatBuilder.fill(
Nat.N1(), Nat.N1(), y.get(0, 0) * (2.0 / (Math.exp(t) + 1.0) - 1.0)),
5.0,
y0,
1.0);
assertEquals(12.0 * Math.exp(6.0) / Math.pow(Math.exp(6.0) + 1.0, 2.0), y1.get(0, 0), 1e-3);
}

@Test
void testZeroRKDP() {
var y1 =
Expand All @@ -56,4 +79,28 @@ void testExponentialRKDP() {

assertEquals(Math.exp(0.1) - Math.exp(0.0), y1.get(0, 0), 1e-3);
}

// Tests RKDP with a time varying solution. From
// http://www2.hawaii.edu/~jmcfatri/math407/RungeKuttaTest.html:
//
// dx/dt = x(2/(eᵗ + 1) - 1)
//
// The true (analytical) solution is:
//
// x(t) = 12eᵗ/(eᵗ + 1)²
@Test
void testRKDPTimeVarying() {
final var y0 = VecBuilder.fill(12.0 * Math.exp(5.0) / Math.pow(Math.exp(5.0) + 1.0, 2.0));

final var y1 =
NumericalIntegration.rkdp(
(Double t, Matrix<N1, N1> y) ->
MatBuilder.fill(
Nat.N1(), Nat.N1(), y.get(0, 0) * (2.0 / (Math.exp(t) + 1.0) - 1.0)),
5.0,
y0,
1.0,
1e-12);
assertEquals(12.0 * Math.exp(6.0) / Math.pow(Math.exp(6.0) + 1.0, 2.0), y1.get(0, 0), 1e-3);
}
}
Loading

0 comments on commit 661bae5

Please sign in to comment.