Skip to content

Commit b677e9a

Browse files
add matrix exponential
1 parent 2033356 commit b677e9a

12 files changed

Lines changed: 386 additions & 165 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Refer to the documentation to quickly integrate and utilize the library's signal
2626
| [Neural Network](doc/neural_network/README.md) | Layers, activations, losses, model |
2727
| [Optimization](doc/optimization/README.md) | Gradient Descent |
2828
| [Regularization](doc/regularization/README.md) | L1 (Lasso), L2 (Ridge) |
29-
| [Math](doc/math/README.md) | CORDIC, Quaternion, MatrixNorms, Step Response Metrics |
29+
| [Math](doc/math/README.md) | CORDIC, Quaternion, MatrixNorms, Step Response Metrics, MatrixExponential |
3030
| [Solvers](doc/solvers/README.md) | Gaussian Elimination, Levinson-Durbin, Durand-Kerner, Cholesky, DARE, Runge-Kutta ODE Integrators (RK4 + Dormand-Prince), Spectral Radius & Discrete Stability Margin, QR Decomposition (Householder / Givens), LU Decomposition with Partial Pivoting |
3131
| [Performance Optimization](doc/performance-optimization/README.md) | Compiler optimizations, SIMD |
3232

ROADMAP.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ Difficulty legend:
2727

2828
| # | Component | Target module | Difficulty |
2929
|----|------------------------------------------------------|---------------------------|------------|
30-
| 29 | Matrix exponential (scaling & squaring + Padé) | `math` | ★★★★☆ |
3130
| 30 | Continuous → discrete conversion (`c2d`) | `math` | ★★★★☆ |
3231
| 31 | Lyapunov / Sylvester equation solvers | `solvers` | ★★★★☆ |
3332
| 32 | Transfer-function ↔ state-space conversion | `control_analysis` | ★★★★☆ |

doc/math/MatrixExponential.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Matrix Exponential
2+
3+
## Overview & Motivation
4+
5+
The matrix exponential is the fundamental solution operator for linear time-invariant (LTI) systems. Given a square matrix $A$, the matrix exponential $e^A$ maps initial conditions of the ODE $\dot{x} = Ax$ to their exact solution at unit time: $x(1) = e^A x(0)$. In embedded control, it appears as the exact discretisation of a continuous-time state-space model — turning the pair $(A_c, B_c)$ into the discrete $(A_d, B_d)$ that an MCU actually executes. Accurate, bounded computation of $e^A$ is therefore a prerequisite for correct control deployment.
6+
7+
## Mathematical Theory
8+
9+
### Series Definition
10+
11+
For a square matrix $A \in \mathbb{R}^{n \times n}$, the matrix exponential is defined by the power series
12+
13+
$$e^A = \sum_{k=0}^{\infty} \frac{A^k}{k!} = I + A + \frac{A^2}{2!} + \frac{A^3}{3!} + \cdots$$
14+
15+
The series converges for every finite matrix and every field of characteristic zero.
16+
17+
### Scaling and Squaring
18+
19+
Truncating the series directly is unreliable for large $\|A\|$ because the intermediate terms grow into a large "hump" before cancelling — a phenomenon of catastrophic cancellation. The scaling-and-squaring algorithm avoids this by exploiting the identity
20+
21+
$$e^A = \left(e^{A/2^s}\right)^{2^s}$$
22+
23+
Choose $s = \max\!\left(0, \lceil \log_2 \|A\|_\infty \rceil\right)$ so that $\|A/2^s\|_\infty \leq 1$, compute $e^{A/2^s}$ accurately on the shrunken argument, then recover $e^A$ by repeated squaring.
24+
25+
### Padé Approximant
26+
27+
For the scaled matrix $\hat{A} = A/2^s$, the exponential is approximated by the diagonal $(q, q)$ Padé rational function
28+
29+
$$e^{\hat{A}} \approx R_q(\hat{A}) = D_q(\hat{A})^{-1} N_q(\hat{A})$$
30+
31+
where $N_q$ and $D_q$ are matrix polynomials whose scalar counterparts are the numerator and denominator of the $(q,q)$ Padé approximant to $e^x$. The coefficients $c_k$ satisfy
32+
33+
$$c_k = \frac{(2q - k)!\, q!}{(2q)!\, k!\, (q-k)!}, \quad k = 0, 1, \ldots, q$$
34+
35+
For $q = 6$ the coefficients are $c_0 = 1$, $c_1 = \tfrac{1}{2}$, $c_2 = \tfrac{5}{44}$, $c_3 = \tfrac{1}{66}$, $c_4 = \tfrac{1}{792}$, $c_5 = \tfrac{1}{15840}$, $c_6 = \tfrac{1}{665280}$.
36+
37+
The even/odd split halves the number of matrix multiplications:
38+
39+
$$V = c_0 I + c_2 \hat{A}^2 + c_4 \hat{A}^4 + c_6 \hat{A}^6$$
40+
$$U = \hat{A}\!\left(c_1 I + c_3 \hat{A}^2 + c_5 \hat{A}^4\right)$$
41+
$$N_q = V + U, \quad D_q = V - U$$
42+
43+
The ratio $D_q^{-1} N_q$ is evaluated by solving the linear system $D_q X = N_q$ column-by-column using LU factorisation with partial pivoting, never forming $D_q^{-1}$ explicitly.
44+
45+
## Complexity Analysis
46+
47+
| Phase | Time | Space | Notes |
48+
|---------------------------|-------------|------------|----------------------------------------------------|
49+
| Infinity-norm | $O(n^2)$ | $O(1)$ | Determines scaling exponent $s$ |
50+
| Matrix powers $A^2, A^4, A^6$ | $3\,O(n^3)$ | $3 n^2$ | Even/odd split; three multiplications total |
51+
| Polynomial evaluation | $O(n^3)$ | $2 n^2$ | Horner-style accumulation of $U$ and $V$ |
52+
| LU solve ($D_q X = N_q$) | $O(n^3)$ | $n^2$ | One factorisation, $n$ substitution passes |
53+
| Squaring ($s$ steps) | $s\,O(n^3)$ | $n^2$ | At most $\lceil \log_2 \|A\|_\infty \rceil$ steps |
54+
| **Total** | $O((6+s)n^3)$ | $O(n^2)$ | Stack-only; no heap |
55+
56+
## Step-by-Step Walkthrough
57+
58+
Consider $A = \begin{bmatrix}0 & -1 \\ 1 & 0\end{bmatrix}$, a rotation generator with $\theta = 1$.
59+
60+
1. **Norm**: $\|A\|_\infty = 1$, so $s = 0$ (no scaling needed).
61+
2. **Polynomial evaluation**: compute $\hat{A}^2 = -I$, $\hat{A}^4 = I$, $\hat{A}^6 = -I$. Then
62+
$$V = (c_0 - c_2 + c_4 - c_6)I, \quad U = (c_1 - c_3 + c_5)\hat{A}$$
63+
3. **Padé ratio**: $R = D^{-1}N$ solved via LU.
64+
4. **Squaring**: none ($s = 0$).
65+
5. **Result**: matches $\begin{bmatrix}\cos 1 & -\sin 1 \\ \sin 1 & \cos 1\end{bmatrix}$ to within float tolerance.
66+
67+
## Pitfalls & Edge Cases
68+
69+
**Catastrophic cancellation without scaling**: naive Padé on a large argument produces entries that nearly cancel, magnifying rounding error. The scaling step ensures $\|\hat{A}\|_\infty \leq 1$ before the rational approximation is applied.
70+
71+
**Nilpotent matrices**: the series terminates in finitely many terms. Scaling-and-squaring handles this transparently; the Padé approximant reduces to a truncated polynomial.
72+
73+
**Stiff systems (large negative eigenvalues)**: the result is bounded because $e^{\lambda}$ with $\lambda \ll 0$ is near zero. Floating-point underflow may drive these entries to zero; this is physically correct and numerically harmless.
74+
75+
**Singular denominator**: $D_q$ is singular only if $e^A$ has a pole, which the matrix exponential never does ($A$ finite $\Rightarrow$ $e^A$ invertible). For the Padé denominator this means near-singularity can occur only at pathological arguments; the LU pivoting detects and gracefully handles it in practice.
76+
77+
**Scaling exponent overflow**: for a matrix with entries $\approx 10^{38}$ the exponent $s$ would be $\approx 126$, requiring 126 squarings. This is accepted behaviour; the algorithm remains correct but slow.
78+
79+
## Variants & Generalizations
80+
81+
**Higher-order Padé**: orders 8, 10, or 13 reduce the required scaling and improve accuracy for modest $\|A\|$. Higham's 2005 algorithm chooses the order adaptively. Order 6 is a reasonable default for embedded float arithmetic.
82+
83+
**Schur decomposition pre-conditioning**: computing $e^A = Q e^T Q^\mathsf{T}$ (with $T$ upper-triangular Schur form) avoids the hump phenomenon entirely and permits reuse of the Schur factors. The additional cost is the Schur decomposition itself ($O(n^3)$) and is not justified for small embedded matrices.
84+
85+
**Taylor series with Horner evaluation**: accurate only for $\|A\| \ll 1$; the scaling step achieves exactly this, making scaling-and-squaring a superset.
86+
87+
## Applications
88+
89+
- **Exact discretisation** (`ContinuousToDiscrete`): $A_d = e^{A_c \Delta t}$.
90+
- **Continuous-time Gramians**: the controllability Gramian $W_c = \int_0^\infty e^{At} B B^\mathsf{T} e^{A^\mathsf{T}t}\,\mathrm{d}t$ requires $e^A$ repeatedly.
91+
- **Lie-group integration**: for rigid-body dynamics the matrix exponential maps the Lie algebra $\mathfrak{so}(3)$ (skew-symmetric matrices) to the Lie group $SO(3)$ (rotation matrices).
92+
- **Linear ODE simulation**: $x(t) = e^{At} x_0$ evaluated on an MCU for trajectory preview.
93+
94+
## Connections to Other Algorithms
95+
96+
`TriangularSolve` (`SolveUnitLowerTriangular`, `SolveUpperTriangular`) performs the back-substitution steps of the LU solve inside `SolvePade`. `MatrixNorms::InfinityNorm` computes the scaling exponent. `ContinuousToDiscrete` (roadmap item 30) is the primary consumer of this algorithm.
97+
98+
## References & Further Reading
99+
100+
- C. Moler, C. Van Loan, "Nineteen Dubious Ways to Compute the Exponential of a Matrix, Twenty-Five Years Later," *SIAM Review*, 45(1), 2003.
101+
- N. J. Higham, "The Scaling and Squaring Method for the Matrix Exponential Revisited," *SIAM Journal on Matrix Analysis and Applications*, 26(4), 2005.
102+
- N. J. Higham, "Functions of Matrices: Theory and Computation," SIAM, 2008, Chapter 10.

doc/math/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ Core mathematical primitives for numerical computation.
1414
| [Triangular Solve](TriangularSolve.md) | Upper-triangular back-substitution shared by Gaussian elimination and QR |
1515
| [Matrix Operations](MatrixOperations.md) | Structural matrix utilities — `Symmetrize` (closest symmetric matrix) |
1616
| [Step Response Metrics](StepResponseMetrics.md) | Rise time, settling time, percent overshoot, peak time, and steady-state error from a bounded step-response vector |
17+
| [Matrix Exponential](MatrixExponential.md) | Scaling-and-squaring with diagonal (6,6) Padé approximant — exact ODE solution operator and discretisation engine |

numerical/math/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ target_sources(numerical.math PRIVATE
1919
HyperbolicFunctions.hpp
2020
LinearTimeInvariant.hpp
2121
Matrix.hpp
22+
MatrixExponential.hpp
2223
MatrixNorms.hpp
2324
MatrixOperations.hpp
2425
QNumber.hpp
@@ -38,6 +39,7 @@ numerical_add_coverage_sources(numerical.math
3839
Cordic.cpp
3940
LinearTimeInvariant.cpp
4041
Matrix.cpp
42+
MatrixExponential.cpp
4143
QNumber.cpp
4244
Quaternion.cpp
4345
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#include "numerical/math/MatrixExponential.hpp"
2+
3+
namespace math
4+
{
5+
template class MatrixExponential<float, 2>;
6+
template class MatrixExponential<float, 3>;
7+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
#pragma once
2+
#if defined(__GNUC__) || defined(__clang__)
3+
#pragma GCC optimize("O3", "fast-math")
4+
#endif
5+
#include "numerical/math/CompilerOptimizations.hpp"
6+
#include "numerical/math/Matrix.hpp"
7+
#include "numerical/math/MatrixNorms.hpp"
8+
#include "numerical/math/TriangularSolve.hpp"
9+
#include <array>
10+
#include <cmath>
11+
#include <cstddef>
12+
#include <type_traits>
13+
14+
namespace math
15+
{
16+
template<typename T, std::size_t N>
17+
class MatrixExponential
18+
{
19+
static_assert(std::is_floating_point_v<T>, "MatrixExponential supports floating-point types");
20+
21+
public:
22+
MatrixExponential() = default;
23+
24+
OPTIMIZE_FOR_SPEED SquareMatrix<T, N> Compute(const SquareMatrix<T, N>& a);
25+
OPTIMIZE_FOR_SPEED SquareMatrix<T, N> Compute(const SquareMatrix<T, N>& a, T dt);
26+
27+
private:
28+
static constexpr T c0{ T{ 1 } };
29+
static constexpr T c1{ T{ 1 } / T{ 2 } };
30+
static constexpr T c2{ T{ 5 } / T{ 44 } };
31+
static constexpr T c3{ T{ 1 } / T{ 66 } };
32+
static constexpr T c4{ T{ 1 } / T{ 792 } };
33+
static constexpr T c5{ T{ 1 } / T{ 15840 } };
34+
static constexpr T c6{ T{ 1 } / T{ 665280 } };
35+
36+
void PadeNumeratorDenominator(const SquareMatrix<T, N>& as,
37+
SquareMatrix<T, N>& num,
38+
SquareMatrix<T, N>& den);
39+
40+
SquareMatrix<T, N> SolvePade(const SquareMatrix<T, N>& den, const SquareMatrix<T, N>& num);
41+
};
42+
43+
template<typename T, std::size_t N>
44+
void MatrixExponential<T, N>::PadeNumeratorDenominator(const SquareMatrix<T, N>& as,
45+
SquareMatrix<T, N>& num,
46+
SquareMatrix<T, N>& den)
47+
{
48+
const auto identity = SquareMatrix<T, N>::Identity();
49+
const auto a2 = as * as;
50+
const auto a4 = a2 * a2;
51+
const auto a6 = a4 * a2;
52+
53+
const auto vEven = identity * c0 + a2 * c2 + a4 * c4 + a6 * c6;
54+
const auto uOdd = as * (identity * c1 + a2 * c3 + a4 * c5);
55+
56+
num = vEven + uOdd;
57+
den = vEven - uOdd;
58+
}
59+
60+
template<typename T, std::size_t N>
61+
SquareMatrix<T, N> MatrixExponential<T, N>::SolvePade(const SquareMatrix<T, N>& den, const SquareMatrix<T, N>& num)
62+
{
63+
SquareMatrix<T, N> lu = den;
64+
std::array<std::size_t, N> piv{};
65+
for (std::size_t i = 0; i < N; ++i)
66+
piv[i] = i;
67+
68+
for (std::size_t k = 0; k < N; ++k)
69+
{
70+
std::size_t p = k;
71+
T maxVal = std::abs(lu.at(k, k));
72+
for (std::size_t i = k + 1; i < N; ++i)
73+
{
74+
T candidate = std::abs(lu.at(i, k));
75+
if (candidate > maxVal)
76+
{
77+
maxVal = candidate;
78+
p = i;
79+
}
80+
}
81+
82+
if (p != k)
83+
{
84+
for (std::size_t j = 0; j < N; ++j)
85+
{
86+
T tmp = lu.at(p, j);
87+
lu.at(p, j) = lu.at(k, j);
88+
lu.at(k, j) = tmp;
89+
}
90+
std::size_t tmpIdx = piv[p];
91+
piv[p] = piv[k];
92+
piv[k] = tmpIdx;
93+
}
94+
95+
for (std::size_t i = k + 1; i < N; ++i)
96+
{
97+
lu.at(i, k) /= lu.at(k, k);
98+
for (std::size_t j = k + 1; j < N; ++j)
99+
lu.at(i, j) -= lu.at(i, k) * lu.at(k, j);
100+
}
101+
}
102+
103+
SquareMatrix<T, N> result{};
104+
for (std::size_t col = 0; col < N; ++col)
105+
{
106+
Vector<T, N> b{};
107+
for (std::size_t i = 0; i < N; ++i)
108+
b.at(i, 0) = num.at(piv[i], col);
109+
110+
Vector<T, N> y = SolveUnitLowerTriangular(lu, b);
111+
Vector<T, N> x = SolveUpperTriangular(lu, y);
112+
113+
for (std::size_t i = 0; i < N; ++i)
114+
result.at(i, col) = x.at(i, 0);
115+
}
116+
117+
return result;
118+
}
119+
120+
template<typename T, std::size_t N>
121+
OPTIMIZE_FOR_SPEED SquareMatrix<T, N> MatrixExponential<T, N>::Compute(const SquareMatrix<T, N>& a)
122+
{
123+
const T norm = InfinityNorm(a);
124+
int s{ 0 };
125+
if (norm > T{ 1 })
126+
{
127+
const T lg = std::log2(norm);
128+
s = static_cast<int>(std::ceil(lg));
129+
if (s < 0)
130+
s = 0;
131+
}
132+
133+
const T scale = T{ 1 } / static_cast<T>(1 << s);
134+
const auto as = a * scale;
135+
136+
SquareMatrix<T, N> num{};
137+
SquareMatrix<T, N> den{};
138+
PadeNumeratorDenominator(as, num, den);
139+
140+
auto r = SolvePade(den, num);
141+
142+
for (int k = 0; k < s; ++k)
143+
r = r * r;
144+
145+
return r;
146+
}
147+
148+
template<typename T, std::size_t N>
149+
OPTIMIZE_FOR_SPEED SquareMatrix<T, N> MatrixExponential<T, N>::Compute(const SquareMatrix<T, N>& a, T dt)
150+
{
151+
const auto adt = a * dt;
152+
return Compute(adt);
153+
}
154+
155+
#ifdef NUMERICAL_TOOLBOX_COVERAGE_BUILD
156+
extern template class MatrixExponential<float, 2>;
157+
extern template class MatrixExponential<float, 3>;
158+
#endif
159+
}

numerical/math/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ target_sources(numerical.math_test PRIVATE
1313
TestGivensRotation.cpp
1414
TestHouseholderTransform.cpp
1515
TestLinearTimeInvariant.cpp
16+
TestMatrixExponential.cpp
1617
TestMatrixNorms.cpp
1718
TestMatrixOperations.cpp
1819
TestQNumber.cpp

0 commit comments

Comments
 (0)