Explanation : RKMK Solvers for Rotations in Exponential Coordinates
This notebook distinguishes RKMK solvers from traditional RK solvers. The use of fixed-step solvers are illustrates for rotations in exponential coordinates.
Kinematic equations governing changes in vehicle states are typically expressed as ordinary differential equations (ODEs). In this sense, navigators are simply numerical approaches to solving initial-value problems (IVPs) on the kinematic ODEs. For rotation groups, the governing ODEs depend on the choice of coordinates. In this tutorial we focus on exponential coordinates and the extension of Runge-Kutta (RK) methods - known as Runge-Kutta Munthe-Kaas (RKMK) methods - to solve the rotation IVP in exponential coordinates.
1import sys, os
2
3sys.path.insert(0, os.path.join("..", "..", ".."))
4
5import numpy as np
6import pandas as pd
7from scipy.integrate import solve_ivp as scipy_solve
8import matplotlib.pyplot as plt
9from typing import Callable
10from functools import partial
11
12# from hitman.lie import LieExponential.exp, LieExponential.log, LieExponential.J_l, LieExponential.hat, LieExponential.vee
13from hitman.rotation import LieExponential as SO
14from hitman.ode import (
15 rk_fixed,
16 rkmk_fixed,
17 butcher_tableaux,
18 verify_ivp_solve,
19 bortz_equation,
20)
Solving ODEs Using Runge Kutta Methods
Before attacking differential equations on rotational groups, we first consider a simple time-invariant ODE. We illustrate the relationship between solver order & step size while demonstrating methods to quantify errors.
Solver Order and Butcher Tableaux
Solver order affects solution accuracy, particularly as the step size increases. To illustrate the point, we define a dictionary of RK ODE solvers with varying order. Solver order is determined by the Butcher tableau [But16]. The dictionary is effectively a solver factory. The dictionary key indicates the tableau (solver order) and the value is a functional. Each functional implementation shares the same function syntax.
We primarily focuse on fixed step-size solvers. However, for now we will include a variable step size solver scipy.integrate.solve_ivp() as an additional reference solution.
1# Create dictionary of ode solvers
2rk_solver_dict = {}
3tableau = ["FwdElr", "ExpMid", "RK3", "RK4", "Variable"]
4for tableau_ in tableau:
5 # Define solver for this tableau
6 def ivp_solve(ode, t, step_size, y_t, tableau=tableau_) -> np.ndarray:
7 if tableau in butcher_tableaux:
8
9 y = rk_fixed(ode, t, step_size, y_t, tableau=butcher_tableaux[tableau])
10 else:
11 # Unknown tableau, use scipy built-in solver (variable step-size)
12 t_end = t + step_size
13 rslt = scipy_solve(
14 ode, np.array([t, t_end]), np.array([y_t]), t_eval=np.array([t_end])
15 )
16 y = rslt.y
17
18 return y
19
20 rk_solver_dict[tableau_] = ivp_solve
Quantifying Solver Perforformance
We define one utility function to quantify solver performance and one to visualize results.
1def quantify_solver(
2 ode: Callable[[float, np.ndarray], np.ndarray],
3 solution: Callable[[float], np.ndarray],
4 solver_dict: dict,
5 t: np.ndarray = np.random.uniform(0, 1, 10),
6 step_size: np.ndarray = np.logspace(-8, -1, num=15),
7) -> pd.DataFrame:
8 r"""Quantify solver performance
9
10 Randomly sample solution in time and quantify solver error while varying step size
11
12 Args:
13 ode: Ordinary differential equation: $f(t,y) : \mathbb{R} \times
14 \mathbb{R}^n\rightarrow\mathbb{R}^n$
15 solution: $y(t) : \mathbb{R} \rightarrow\mathbb{R}^n$
16 solver_dict: Dictionary of solvers to evaluate
17 t : selection of start times over which to average results
18 step_size : step sizes to evaluate
19
20 Returns:
21 Average error over step size for each solver
22 """
23
24 # Initialize a dictionary to store average errors for each tableau
25 errors_dict = {}
26 for name, ivp_solve in solver_dict.items():
27 # Compute average error for each step size
28 err, _ = verify_ivp_solve(
29 ode,
30 solution,
31 t,
32 step_size,
33 solver=ivp_solve,
34 )
35 errors_dict[name] = err
36
37 return pd.DataFrame(errors_dict, index=step_size)
38
39
40def plot_solver_performance(df, title="ODE solver error for RK methods"):
41 # Plot the result
42 for tableau_ in df.columns:
43 plt.loglog(df.index, df[tableau_], label=tableau_)
44 # Add labels and title
45 plt.xlabel("Step Size (step_size)")
46 plt.ylabel("Average Error")
47 plt.title(title)
48 plt.legend()
49 plt.grid(True, which="both", ls="--")
50 plt.show()
Example Trajectory: Time-Invariant ODE
Define an example ODE with known solution. In this case, the ODE is time-invariant
with a solution
1# Define the ODE and solution
2class OdeExponential:
3 def __init__(self, C: float = 0.1):
4 self.C = C
5
6 def sol(self, t):
7 return self.C * np.exp(t)
8
9 def ode(self, t, y):
10 return y
Our example ODE and solution can be used to quantify solver performance using the functions above.
1ref_ode = OdeExponential()
2ode = ref_ode.ode
3solution = ref_ode.sol
4solver_dict = rk_solver_dict
5
6df = quantify_solver(ode, solution, solver_dict)
7plot_solver_performance(df)
Binary Metrics for Unit Tests
Considering a specific step size (1e-2 in this case), we should see a significant difference between solver accuracy with order. Additionally, error should be small for the highest order solver (RK4).
1# Display the DataFrame with log10 of the index
2dfd = df.copy()
3dfd.index = np.log10(dfd.index)
4display(dfd)
5
6# Contrast performance at specific tau
7tau_pick = 1e-2
8tol = -2 # expected error magnitude reduction with step-size
9mask = df.index == tau_pick
10assert np.count_nonzero(mask) == 1, (
11 "Exactly one true value expected. %g is not a valid index" % tau_pick
12)
13row = df.loc[mask]
14log_row = row.map(np.log10)
15assert np.all(np.diff(log_row) < tol), "Errors did not decrease as expected"
16
17tableau = "RK4"
18tol = 1e-11
19err = row.loc[tau_pick, tableau]
20assert err < tol, "Absolute error exceeds expected: %.2e, for %s with step size %g." % (
21 err,
22 tableau,
23 tau_pick,
24)
| FwdElr | ExpMid | RK3 | RK4 | Variable | |
|---|---|---|---|---|---|
| -8.0 | 1.110223e-17 | 4.163336e-18 | 4.163336e-18 | 4.163336e-18 | 4.163336e-18 |
| -7.5 | 8.187895e-17 | 1.110223e-17 | 1.110223e-17 | 1.110223e-17 | 1.110223e-17 |
| -7.0 | 8.160139e-16 | 1.526557e-17 | 1.526557e-17 | 1.526557e-17 | 1.249001e-17 |
| -6.5 | 8.182344e-15 | 2.359224e-17 | 2.359224e-17 | 2.359224e-17 | 1.387779e-17 |
| -6.0 | 8.196083e-14 | 1.110223e-17 | 1.110223e-17 | 1.110223e-17 | 1.387779e-17 |
| -5.5 | 8.196208e-13 | 2.081668e-17 | 2.081668e-17 | 2.081668e-17 | 2.081668e-17 |
| -5.0 | 8.196269e-12 | 1.942890e-17 | 1.387779e-17 | 1.387779e-17 | 9.714451e-18 |
| -4.5 | 8.196339e-11 | 8.604228e-16 | 2.220446e-17 | 2.220446e-17 | 2.081668e-17 |
| -4.0 | 8.196526e-10 | 2.731149e-14 | 1.387779e-17 | 1.387779e-17 | 1.387779e-17 |
| -3.5 | 8.197117e-09 | 8.640255e-13 | 6.522560e-17 | 1.387779e-17 | 1.387779e-17 |
| -3.0 | 8.198986e-08 | 2.732767e-11 | 6.838974e-15 | 8.326673e-18 | 8.326673e-18 |
| -2.5 | 8.204899e-07 | 8.646444e-10 | 6.834394e-13 | 4.246603e-16 | 1.665335e-17 |
| -2.0 | 8.223642e-06 | 2.738928e-08 | 6.843894e-11 | 1.368350e-13 | 4.857226e-17 |
| -1.5 | 8.283336e-05 | 8.708346e-07 | 6.873637e-09 | 4.342675e-11 | 4.451023e-14 |
| -1.0 | 8.476430e-04 | 2.801776e-05 | 6.969125e-07 | 1.389139e-08 | 4.224116e-11 |
Solving ODEs on Rotational Groups
Rotations are often expressed as a matrix \(R\in SO(3)\). Specifying differential equations in \(SO(3)\) is not convenient since \(SO(3)\) is inherently three dimensional. In the following we motivate specifying the ODE in exponential coordinates. Once we define the ODE and a numeric solution, we can apply traditional RK solvers and assess accuracy. Finally, we will demonstrate RKMK solvers which operate on a function rather than the ODE directly.
ODE For Rotational Groups
Consider the attitude trajectory
where \(\Delta R, R \in SO(3)\). Since \(SO(3)\) is inherently three dimensional, \(\Delta R\) admits prameterization in a three-dimensional space. Using exponential coordinates \(\phi\in \mathbb{R}^3\), we have
The differential expression for \(\dot{\phi}\) depends on the independent variable \(\phi\) and a time-varying function \(\omega : \mathbb{R}\rightarrow\mathbb{R}^3\)
Here, \(J_\phi^{-1}\in GL(3)\) is the inverse Jacobian of the exponential mapping. For any \(\phi\in\mathbb{R}^3\), \(J\) is a \(3\times3\) invertible matrix.
We seek the solution to the initial value problem \(\phi(\tau)\) given \(\phi(0) = 0\) governed by the ODE above. Ignoring phase wrapping (assuming \(\| \phi \|< \pi\)), this is equivalent to solving \( \Delta R\) given \(R(t-\tau)\).
Example Trajectory: Exponential Coordinates
Here we define the trajectory by specifying \(\phi : \mathbb{R}\rightarrow\mathbb{R}^3\) as a polynomial curve. Note, this requires \(\| \phi \|< \pi\) for all \(t\) to avoid phase wrapping.
1from scipy.interpolate import BPoly
2from hitman.plot import plot_hermite_spline
3
4random_matrix = np.random.randn(5, 3)
5assert (
6 np.max(np.linalg.norm(random_matrix, axis=1)) < np.pi
7), "Curve control points must lie within a sphere with radius pi."
8
9poly_phi = BPoly(random_matrix[:, np.newaxis, :], [0.0, 1.0])
10
11_, _ = plot_hermite_spline(poly_phi)
Evaluating \(\omega\) requires both \(\phi\) and \(\dot{\phi}\) which are available analytically.
1def eval_omega(t, phi=poly_phi, phidot=poly_phi.derivative(1)):
2 """Omega function
3
4 :param t: N-vector of timesteps
5 :return: Nx3 matrix of omega(t)
6 """
7 return SO.J_r(phi(t)) @ phidot(t)
8
9
10bortz_ode = partial(bortz_equation, omega=eval_omega)
11solution_bortz = poly_phi
Runge-Kutta Methods for Rotation Groups
The same RK solvers can be applied to differential equations on the exponential coordinates. We expect similar improvement in solution quality with solver order.
1ode = bortz_ode
2solution = solution_bortz
3# Remove Variable solver
4solver_dict = dict(rk_solver_dict.items() - [("Variable", rk_solver_dict["Variable"])])
5
6df = quantify_solver(ode, solution, solver_dict, t=np.random.uniform(0, 0.1, 20))
7plot_solver_performance(df)
Runge-Kutta Munthe-Kaas Solver
For Lie-Group ODEs, the differential equation separates as the product of two terms: the current angle \(\phi\), and the time-varying rate \(\omega\). Instead of supplying the complete ODE to the solver \({f : \mathbb{R}\times \mathbb{R}^n\rightarrow \mathbb{R}^n}\), as a traditional RK-method, we can simply supply \({\omega : \mathbb{R}\rightarrow \mathbb{R}^n}\). The latter approach is referred to as RKMK methods [IMKNorsettZ00].
Adjusting the solver arguments necessitates re-constructing our solver dictionary.
1# Create dictionary of ode solvers
2rkmk_solver_dict = {}
3tableau = ["FwdElr", "ExpMid", "RK3", "RK4"]
4for tableau_ in tableau:
5 # Define solver for this tableau
6 if tableau_ in butcher_tableaux:
7
8 def ivp_solve(f, t, step_size, y_t, tableau=tableau_) -> np.ndarray:
9 y = rkmk_fixed(f, t, step_size, y_t, tableau=butcher_tableaux[tableau])
10 return y
11
12 else:
13 raise ValueError("Unknown tableau: %s" % tableau_)
14
15 rkmk_solver_dict[tableau_] = ivp_solve
We can now score the RKMK solvers using the same verifier method. The difference is that we supply \(\omega\) instead of the prior ODE as the functional. We expect the same improvement in solution quality with solver order.
1f = eval_omega # Note: this is a function, not an ODE
2solution = solution_bortz
3solver_dict = rkmk_solver_dict
4
5df = quantify_solver(f, solution, solver_dict, t=np.random.uniform(0, 0.1, 20))
6plot_solver_performance(df)
The distinction here is subtle. One (traditional) approach to propagating attitude is to form an ODE by building-in angular rate. In this case, the angular rate effects time-variability in the differential equation. Alternatively, only angular rate is provided as a functional to the RKMK solver. The full ODE is not required as the form of the ODE is presumed by the RKMK solver. RKMK methods are a natural fit for directly incorporating inertial measurements in vehicle state propagation.