A Field Guide to Embedded DSP, Control, and Estimation Algorithms
August 01, 2026
Many problems in signal processing, communications, and control require knowing the frequency content of a signal. The Discrete Fourier Transform (DFT) answers this question exactly, but its direct computation costs \(O(N^2)\) multiplications — prohibitive for real-time embedded systems processing thousands of samples.
The Fast Fourier Transform is a family of algorithms that compute the DFT in \(O(N \log N)\) operations by exploiting symmetry and periodicity of the complex exponential. This library implements the Radix-2 Cooley-Tukey variant, which recursively splits an \(N\)-point DFT into two \(N/2\)-point DFTs, requiring \(N\) to be a power of two.
The core insight is that the DFT matrix has a recursive block structure: half of its entries are shared between the even-indexed and odd-indexed sub-problems, so most of the work can be reused rather than recomputed.
The Discrete Fourier Transform of a length-\(N\) sequence \(x[n]\) is defined as:
\[X[k] = \sum_{n=0}^{N-1} x[n] \, e^{-j2\pi kn/N}, \quad k = 0, 1, \ldots, N-1\]
The inverse DFT recovers the original sequence:
\[x[n] = \frac{1}{N} \sum_{k=0}^{N-1} X[k] \, e^{j2\pi kn/N}\]
We define the twiddle factor \(W_N = e^{-j2\pi/N}\), so \(X[k] = \sum_{n} x[n] \, W_N^{kn}\).
Split the sum into even-indexed and odd-indexed terms:
\[X[k] = \underbrace{\sum_{m=0}^{N/2-1} x[2m] \, W_{N/2}^{km}}_{E[k]} \;+\; W_N^k \underbrace{\sum_{m=0}^{N/2-1} x[2m+1] \, W_{N/2}^{km}}_{O[k]}\]
Because \(E[k]\) and \(O[k]\) are periodic with period \(N/2\):
\[X[k] = E[k] + W_N^k \, O[k]\] \[X[k + N/2] = E[k] - W_N^k \, O[k]\]
This pair of equations is the butterfly operation: two outputs from two inputs and one twiddle-factor multiplication.
The recursive decomposition reorders input by bit-reversing the sample indices. For in-place computation the input array is pre-permuted so that butterfly stages proceed sequentially from the smallest sub-problems to the full \(N\)-point result.
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(N \log_2 N)\) | \(O(N)\) | \(N\) must be a power of 2 |
Why \(O(N \log N)\): There are \(\log_2 N\) butterfly stages, each performing \(N/2\) butterfly operations (one complex multiply + two complex adds). Total: \(\frac{N}{2} \log_2 N\) complex multiplications.
Twiddle factors are pre-computed and stored in a lookup table of size \(N/2\), so no trigonometric evaluations occur at runtime.
Input: \(x = [1, 2, 3, 4]\), \(N = 4\)
Step 1 — Bit-reversal permutation
| Index (binary) | Reversed | Value |
|---|---|---|
| 0 (00) | 0 (00) | 1 |
| 1 (01) | 2 (10) | 3 |
| 2 (10) | 1 (01) | 2 |
| 3 (11) | 3 (11) | 4 |
Reordered: \([1, 3, 2, 4]\)
Step 2 — Stage 1 (size-2 butterflies), twiddle \(W_2^0 = 1\)
Result: \([4, -2, 6, -2]\)
Step 3 — Stage 2 (size-4 butterfly), twiddles \(W_4^0 = 1\), \(W_4^1 = -j\)
Output: \(X = [10,\; -2+2j,\; -2,\; -2-2j]\)
Verification: \(X[0] = 1+2+3+4 = 10\) ✓
| Variant | Key Difference |
|---|---|
| Mixed-radix FFT | Supports arbitrary composite \(N\) using radix-2, -3, -5, etc. stages |
| Split-radix FFT | Reduces multiplications by ~33 % compared to standard radix-2 |
| Real-valued FFT | Exploits conjugate symmetry of real inputs to halve work and memory |
| Bluestein’s algorithm | Handles arbitrary \(N\) by converting DFT to a circular convolution |
| Inverse FFT | Same structure; negate twiddle exponents and scale by \(1/N\) |
graph LR
FFT --> PSD["Power Spectral Density"]
FFT --> DCT["Discrete Cosine Transform"]
WIN["Window Functions"] --> FFT
FFT -.-> DK["Durand-Kerner (polynomial eval)"]
| Algorithm | Relationship |
|---|---|
| Power Spectral Density | Calls FFT internally on each windowed segment |
| Discrete Cosine Transform | Computed via FFT with input rearrangement and twiddle-factor post-processing |
| Window Functions | Applied before FFT to reduce spectral leakage |
Most sensor data is real-valued: audio samples, vibration measurements, ADC readings. A general complex FFT applied to such data wastes half its work because the imaginary input channel is identically zero. The real-input FFT exploits this structure to deliver roughly twice the throughput and half the memory of a complex FFT at the same transform length. On bandwidth- and RAM-limited embedded targets this difference is significant.
The RFFT is a specialisation of the length-\(N\) Discrete Fourier Transform
\[X[k] = \sum_{n=0}^{N-1} x[n]\, W_N^{nk}, \quad W_N = e^{-j2\pi/N}\]
for the case where every \(x[n]\) is real-valued. Because \(x[n] \in \mathbb{R}\), the spectrum satisfies Hermitian symmetry: \(X[N-k] = X^*[k]\). Consequently only the \(N/2 + 1\) bins \(k = 0, \dots, N/2\) carry independent information.
Split \(x\) into its even- and odd-indexed subsequences:
\[x_e[n] = x[2n], \qquad x_o[n] = x[2n+1], \qquad n = 0, \dots, N/2 - 1.\]
Their length-\((N/2)\) DFTs are \(E[k]\) and \(O[k]\). The full transform decomposes as
\[X[k] = E[k] + W_N^k \, O[k], \qquad k = 0, \dots, N/2 - 1,\]
\[X[N/2 + k] = E[k] - W_N^k \, O[k], \qquad k = 0, \dots, N/2 - 1.\]
The twiddle factor \(W_N^k = e^{-j2\pi k/N}\) is precomputed for \(k = 1, \dots, N/2 - 1\). The boundary cases are purely real:
\[X[0] = E[0] + O[0], \qquad X[N/2] = E[0] - O[0].\]
Given the \(N/2 + 1\) unique bins, the even and odd sub-spectra are recovered using the inverse of the split relations. For \(k = 1, \dots, N/2 - 1\):
\[E[k] = \tfrac{1}{2}(X[k] + X^*[N/2 - k]),\]
\[O[k] = W_N^{-k} \cdot \tfrac{1}{2}(X[k] - X^*[N/2 - k]).\]
Two independent length-\((N/2)\) inverse DFTs then reconstruct \(x_e\) and \(x_o\), which interleave back to \(x\).
| Case | Time | Space | Notes |
|---|---|---|---|
| Forward | \(O(N \log N / 2)\) | \(O(N)\) stack (bounded) | Two half-length DFTs plus \(O(N)\) combine pass |
| Inverse | \(O(N \log N / 2)\) | \(O(N)\) stack (bounded) | Two half-length IDFTs plus interleave pass |
The constant factor is approximately half that of a complex \(N\)-point FFT because both sub-DFTs exploit real-input symmetry internally (they receive real-only vectors).
Input: \(x = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]\) (unit impulse, \(N = 16\)).
static_assert enforces it at
compile time.ConvolutionCorrelation.PowerDensitySpectrum.Given a noisy signal, a natural question is: how is the signal’s power distributed across frequencies? A single FFT of the entire record gives a very noisy estimate — the variance of the periodogram does not decrease as the record grows. Welch’s method trades frequency resolution for statistical reliability by dividing the signal into overlapping, windowed segments, computing a periodogram for each, and averaging them. The result is a smooth, consistent estimate of the Power Spectral Density (PSD).
This is the standard approach in vibration analysis, audio engineering, communication system design, and any domain where reliable spectral estimates matter more than the finest possible frequency resolution.
For a wide-sense stationary process \(x(t)\), the PSD is the Fourier transform of the autocorrelation function (Wiener-Khinchin theorem):
\[S_{xx}(f) = \int_{-\infty}^{\infty} R_{xx}(\tau) \, e^{-j2\pi f\tau} \, d\tau\]
In practice we estimate \(S_{xx}\) from a finite record.
The periodogram of a length-\(N\) segment \(x[n]\) is:
\[\hat{P}(f_k) = \frac{1}{N} \left| \sum_{n=0}^{N-1} x[n] \, e^{-j2\pi kn/N} \right|^2 = \frac{1}{N} |X[k]|^2\]
This is an unbiased but inconsistent estimator — its variance does not shrink as \(N \to \infty\).
\[x_i[n] = x[n + iS], \quad i = 0, 1, \ldots, K-1\]
\[w_i[n] = w[n] \cdot x_i[n]\]
\[P_i[k] = \frac{1}{N} |W_i[k]|^2\]
\[\hat{S}_{xx}[k] = \frac{1}{K} \sum_{i=0}^{K-1} P_i[k]\]
The averaging reduces variance by a factor of approximately \(K\) (slightly less due to overlap correlation).
\[\Delta f = \frac{f_s}{N}\]
Increasing segment size \(N\) improves resolution but reduces the number of averages \(K\) for a fixed-length record.
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(K \cdot N \log N)\) | \(O(N)\) | \(K\) segments, each requiring one FFT |
Where \(K = \lfloor (L - N) / S \rfloor + 1\) and \(S = N \cdot (1 - \text{overlap fraction})\).
Why: Each segment requires an \(O(N \log N)\) FFT, and there are \(K\) segments. The averaging and magnitude-squared operations are \(O(N)\) per segment.
Input: \(x = [1, 3, 5, 7, 6, 4, 2, 0]\), segment size \(N=4\), 50 % overlap, rectangular window.
Step 1 — Segmentation (step \(S = 2\)):
| Segment | Indices | Values |
|---|---|---|
| \(x_0\) | 0–3 | \([1, 3, 5, 7]\) |
| \(x_1\) | 2–5 | \([5, 7, 6, 4]\) |
| \(x_2\) | 4–7 | \([6, 4, 2, 0]\) |
Step 2 — Window (rectangular → no change)
Step 3 — FFT and periodogram for \(x_0 = [1, 3, 5, 7]\):
(Repeat for \(x_1\) and \(x_2\).)
Step 4 — Average the three periodograms element-wise.
Output: One-sided PSD estimate at frequencies \(0, \frac{f_s}{4}, \frac{f_s}{2}\).
| Variant | Key Difference |
|---|---|
| Bartlett’s method | Non-overlapping segments, rectangular window (special case of Welch with 0 % overlap) |
| Modified periodogram | Single segment with a non-rectangular window |
| Multitaper method | Uses multiple orthogonal windows (tapers) per segment for lower bias |
| Cross-spectral density | Computes \(S_{xy}\) between two signals instead of the auto-spectrum |
| Coherence | \(|\gamma_{xy}|^2 = |S_{xy}|^2 / (S_{xx} S_{yy})\) — measures linear dependence between two signals at each frequency |
graph LR
SIG["Input Signal"] --> WIN["Window Functions"]
WIN --> FFT["Fast Fourier Transform"]
FFT --> PSD["Power Spectral Density"]
PSD --> YW["Yule-Walker (parametric alternative)"]
| Algorithm | Relationship |
|---|---|
| Fast Fourier Transform | Core building block — each segment is transformed via FFT |
| Window Functions | Applied to each segment before FFT to control leakage |
| Yule-Walker | Parametric alternative — estimates PSD from an AR model instead of averaging periodograms |
The Discrete Cosine Transform projects a real-valued signal onto a set of cosine basis functions at different frequencies. Unlike the DFT, the DCT produces purely real coefficients and exhibits superior energy compaction — most of the signal’s energy concentrates in a few low-frequency coefficients. This property makes it the backbone of lossy compression standards (JPEG, MPEG, AAC).
This library implements DCT-II (the most common variant) by leveraging the FFT: the input is rearranged, transformed via a real-valued FFT, and post-processed with twiddle factors. This approach inherits the FFT’s \(O(N \log N)\) efficiency rather than requiring a dedicated \(O(N^2)\) computation.
For a length-\(N\) sequence \(x[n]\), the DCT-II is defined as:
\[X[k] = \sum_{n=0}^{N-1} x[n] \cos\!\left(\frac{\pi}{N}\left(n + \tfrac{1}{2}\right) k\right), \quad k = 0, 1, \ldots, N-1\]
The inverse (DCT-III, also called IDCT) recovers \(x[n]\):
\[x[n] = \frac{1}{N}\left[\frac{X[0]}{2} + \sum_{k=1}^{N-1} X[k] \cos\!\left(\frac{\pi}{N}\left(n + \tfrac{1}{2}\right) k\right)\right]\]
The DCT can be computed via the DFT of a reordered sequence:
Cosine functions are symmetric (even functions). The DCT implicitly mirrors the signal rather than making it periodic, avoiding the boundary discontinuities that cause spectral leakage in the DFT. This is why DCT coefficients decay faster and energy compaction is better.
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(N \log N)\) | \(O(N)\) | Dominated by the internal FFT; twiddle post-processing is \(O(N)\) |
The reordering step and twiddle multiplication are both linear, so the overall cost is determined by the FFT.
Input: \(x = [1, 2, 3, 4]\), \(N = 4\)
Step 1 — Reorder for FFT
Even-indexed positions filled left-to-right, odd-indexed filled right-to-left:
\[y = [x[0],\; x[2],\; x[3],\; x[1]] = [1, 3, 4, 2]\]
Step 2 — Compute FFT
\[Y = \text{FFT}([1, 3, 4, 2]) = [10,\; -3+j,\; -2,\; -3-j]\]
Step 3 — Apply twiddle factors \(W[k] = e^{-j\pi k/8}\)
| \(k\) | \(W[k]\) | \(W[k] \cdot Y[k]\) | \(X[k] = 2 \cdot \text{Re}(\cdot)\) |
|---|---|---|---|
| 0 | 1 | 10 | 20 |
| 1 | \(e^{-j\pi/8}\) | ≈ −2.22 − 1.90j | ≈ −4.44 |
| 2 | \(e^{-j\pi/4}\) | ≈ −1.41 + 1.41j | ≈ −2.83 |
| 3 | \(e^{-j3\pi/8}\) | ≈ −0.24 + 3.07j | ≈ −0.47 |
Output: \(X \approx [20, -4.44, -2.83, -0.47]\)
Notice how most of the energy is in \(X[0]\) (the DC component) — energy compaction in action.
| Variant | Description |
|---|---|
| DCT-I | Symmetric extension; used in some filter bank designs |
| DCT-III (IDCT) | Inverse of DCT-II; used for reconstruction in JPEG decoding |
| DCT-IV | Self-inverse; basis of the MDCT used in MP3 and AAC |
| MDCT | Modified DCT with 50 % overlap; foundation of modern audio codecs |
| 2-D DCT | Applied row-then-column for image compression (JPEG 8×8 blocks) |
graph LR
DCT["Discrete Cosine Transform"]
FFT["Fast Fourier Transform"]
WIN["Window Functions"]
FFT --> DCT
WIN -.-> DCT
| Algorithm | Relationship |
|---|---|
| Fast Fourier Transform | DCT is computed via FFT with input reordering and twiddle post-processing |
| Window Functions | Some DCT applications use windowing to control boundary effects |
Real-time signal monitoring requires lightweight, stateful primitives that track envelope, periodicity, and energy without buffering entire waveforms. Three composable detectors cover the most common needs: holding the peak magnitude for VU-style metering, counting sign reversals to estimate frequency, and smoothing instantaneous power to approximate RMS level. All three operate in O(1) time and O(1) memory per sample, making them suitable for tight interrupt service routines.
Let \(m_n = |x_n|\). With a decay coefficient \(d \in (0, 1]\), the held peak evolves as:
\[p_n = \max(m_n,\; d \cdot p_{n-1})\]
When \(d = 1\) the peak is infinite-hold; when \(d < 1\) it releases exponentially toward zero between new maxima.
A zero crossing occurs at sample \(n\) when the sign of \(x_n\) differs from the sign of \(x_{n-1}\) and \(|x_n|\) exceeds a hysteresis threshold \(h \geq 0\):
\[\text{crossed}_n = \bigl(\mathrm{sgn}(x_n) \neq \mathrm{sgn}(x_{n-1})\bigr) \land \bigl(|x_n| > h\bigr)\]
The instantaneous fundamental frequency of a periodic signal can be estimated from the cumulative count \(C\) over a window of \(N\) samples at sampling period \(T_s\):
\[f_0 \approx \frac{C}{2 \, N \, T_s}\]
A one-pole IIR filter is applied to \(x^2\), yielding an exponentially weighted mean-square:
\[E_n = E_{n-1} + \alpha\,(x_n^2 - E_{n-1}), \quad \alpha \in (0, 1)\]
The RMS envelope is:
\[r_n = \sqrt{E_n}\]
This is structurally identical to an exponential moving average applied to the squared signal.
| Detector | Time per sample | State words |
|---|---|---|
| Peak Hold | O(1) — 1 mul, 1 compare, 1 abs | 1 scalar |
| Zero-Crossing Counter | O(1) — 1 compare, 1 abs | 1 scalar + 1 uint32 |
| RMS Envelope | O(1) — 1 MAC + 1 sqrt | 1 scalar |
No buffers, no windows, no dynamic allocation.
| n | \(x_n\) | \(m_n\) | \(d \cdot p_{n-1}\) | \(p_n\) |
|---|---|---|---|---|
| 0 | 0.8 | 0.8 | 0.0 | 0.8 |
| 1 | 0.0 | 0.0 | 0.4 | 0.4 |
| 2 | 0.0 | 0.0 | 0.2 | 0.2 |
| 3 | 0.0 | 0.0 | 0.1 | 0.1 |
| n | \(x_n\) | prev sign | curr sign | crossed | C |
|---|---|---|---|---|---|
| 0 | +1 | 0 | + | no | 0 |
| 1 | -1 | + | - | yes | 1 |
| 2 | +1 | - | + | yes | 2 |
| 3 | -1 | + | - | yes | 3 |
\(E_0 = 0\); after \(n\) steps, \(E_n = 0.16\,(1-(1-0.25)^n)\). At \(n = 20\): \(E_{20} \approx 0.1599\), \(r_{20} \approx 0.3999\).
Audio, filter, and control-system specifications express signal
levels and filter performance in decibels (dB) because the human
auditory system and most engineering metrics scale logarithmically with
amplitude ratio. Specifying a stop-band attenuation of 60 dB or a
pass-band ripple of 0.1 dB is natural and compact; the equivalent linear
ratios (1 000 : 1 and 1.01161 : 1) are not. A small set of conversion
primitives — ToDecibels, FromDecibels, and two
derived helpers for attenuation and ripple — centralises this conversion
and eliminates scattered, error-prone inline 20·log10
expressions throughout the rest of the library.
For a positive amplitude ratio \(r > 0\), the equivalent level in decibels is:
\[L_{\mathrm{dB}} = 20 \log_{10}(r)\]
The factor 20 (rather than 10) reflects the voltage/pressure convention: power is proportional to the square of amplitude, so a doubling of amplitude (\(r = 2\)) gives a 6 dB increase, matching the \(10 \log_{10}(4) = 6.02\) dB power equivalent.
\[r = 10^{L_{\mathrm{dB}}/20}\]
This inverse is exact for all finite \(L_{\mathrm{dB}}\); no guard is needed on the output side.
\(\log_{10}(0) = -\infty\); negative ratios are physically meaningless. Both cases are mapped to a finite floor value \(L_{\min}\) chosen well below any engineering specification of interest:
\[L_{\mathrm{dB}} = \max\!\left(20\log_{10}(r),\; L_{\min}\right), \quad r > 0\] \[L_{\mathrm{dB}} = L_{\min}, \quad r \leq 0\]
A floor of \(-160\,\mathrm{dB}\) corresponds to an amplitude ratio below \(10^{-8}\), safely beyond the dynamic range of any practical floating-point computation in 32-bit single precision.
Stop-band attenuation between a pass-band ratio \(r_p\) and a stop-band ratio \(r_s\):
\[A = L_{\mathrm{dB}}(r_p) - L_{\mathrm{dB}}(r_s)\]
Pass-band ripple between the maximum and minimum in-band ratios \(r_{\max}\) and \(r_{\min}\):
\[\Delta = L_{\mathrm{dB}}(r_{\max}) - L_{\mathrm{dB}}(r_{\min})\]
Both are simple differences in decibel space, exploiting the logarithm identity \(\log(a/b) = \log a - \log b\).
| Operation | Time | Space | Notes |
|---|---|---|---|
ToDecibels |
O(1) | O(1) | One log10 + one max + one
mul |
FromDecibels |
O(1) | O(1) | One pow |
AttenuationDb |
O(1) | O(1) | Two ToDecibels + one subtraction |
RippleDb |
O(1) | O(1) | Two ToDecibels + one subtraction |
No state, no buffers. All operations are pure functions.
Converting a ratio of 10 to decibels:
Round-trip for \(r = 0.5\):
ToDecibels(0.5) = \(20 \cdot
\log_{10}(0.5) \approx -6.0206\,\mathrm{dB}\).FromDecibels(-6.0206) = \(10^{-6.0206/20} \approx 0.5\).log10; the
floor guard prevents propagation into downstream computations.fast-math enabled, the compiler may fuse or
reorder floating-point operations. The log10 result is
still monotone and the floor remains correct because it uses
std::max, which is not reordered away.FromDecibels has no floor; at very large positive dB
values the result overflows to +inf in float — this is
expected behaviour for out-of-range inputs.FrequencyResponse::Calculate() already returns magnitude in
dB using \(20 \log_{10}\); these
helpers provide the same conversion for ad-hoc analysis.ToDecibels avoids duplication.control_analysis::FrequencyResponse internally applies
\(20 \log_{10}(\|H\|)\) on its
magnitude output vector; these helpers are the scalar equivalent exposed
for library consumers.RippleDb feeds directly
into filter-design acceptance testing alongside the
step/transient-response metrics in the evaluation primitives
family.When a system needs to detect or measure a single known frequency — a DTMF touch-tone, a pilot sync tone, mains-hum, or a narrowband alarm — computing a full FFT wastes every cycle spent on bins that will never be read. The Goertzel algorithm computes exactly one DFT bin using a second-order recurrence that processes samples one at a time, requiring no input buffer and no twiddle-factor table. On the smallest microcontrollers with a few hundred bytes of RAM, this distinction is decisive.
Let \(x[n]\), \(n = 0, \ldots, N-1\), be a block of \(N\) real samples. The DFT coefficient at bin \(k\) is:
\[X[k] = \sum_{n=0}^{N-1} x[n]\, e^{-j2\pi kn/N}\]
Define the resonator coefficient:
\[c = 2\cos\!\left(\frac{2\pi k}{N}\right)\]
The DFT sum can be reorganised as the output of a single-pole IIR filter evaluated at \(n = N\). Introduce the auxiliary sequence:
\[s[n] = x[n] + c\,s[n-1] - s[n-2], \quad s[-1] = s[-2] = 0\]
After \(N\) steps the DFT bin follows from the final two state values:
\[X[k] = s[N-1] - s[N-2]\,e^{-j2\pi k/N}\]
Separating real and imaginary parts:
\[\mathrm{Re}\{X[k]\} = s[N-1] - s[N-2]\cos\!\left(\tfrac{2\pi k}{N}\right)\]
\[\mathrm{Im}\{X[k]\} = s[N-2]\sin\!\left(\tfrac{2\pi k}{N}\right)\]
The magnitude can be obtained without the final trigonometric products using the identity:
\[|X[k]|^2 = s[N-1]^2 + s[N-2]^2 - c\,s[N-1]\,s[N-2]\]
so \(|X[k]| = \sqrt{s[N-1]^2 + s[N-2]^2 - c\,s[N-1]\,s[N-2]}\), saving two multiplies on the hot path.
A physical frequency \(f_t\) sampled at \(f_s\) maps to bin:
\[k = \mathrm{round}\!\left(\frac{f_t}{f_s}\,N\right)\]
Frequency resolution is \(\Delta f = f_s / N\).
| Case | Time | Space | Notes |
|---|---|---|---|
| Per sample | \(O(1)\) | \(O(1)\) | One multiply, two adds, no table lookup |
| Full block | \(O(N)\) | \(O(1)\) | Two state words; trig only at block end |
For \(B\) bins of interest the total cost is \(O(BN)\). An FFT computing all \(N\) bins costs \(O(N\log N)\), so the Goertzel approach is cheaper when \(B \ll \log_2 N\) — typically \(B \le 4\) for a 256-point transform.
Consider \(N = 4\), \(k = 1\), \(x = [1, 0, -1, 0]\).
\(c = 2\cos(\pi/2) = 0\)
| \(n\) | \(x[n]\) | \(s[n] = x[n] + 0\cdot s[n-1] - s[n-2]\) |
|---|---|---|
| 0 | 1 | \(1 + 0 - 0 = 1\) |
| 1 | 0 | \(0 + 0 - 1 = -1\) |
| 2 | -1 | \(-1 + 0 - 0 = -1\) (note: \(s[-1]=0\)) |
| 3 | 0 | \(0 + 0 - (-1) = 1\) |
\(\cos(2\pi/4) = 0\), \(\sin(2\pi/4) = 1\)
\(\mathrm{Re}\{X[1]\} = 1 - (-1)\cdot 0 = 1\)
\(\mathrm{Im}\{X[1]\} = (-1)\cdot 1 = -1\)
Direct DFT check: \(X[1] = 1 - j\). Matches.
The recurrence pole sits exactly on the unit circle, making the
filter marginally stable. Error accumulates with each block if
Reset is not called between blocks. The algorithm is
designed for finite, bounded blocks of exactly \(N\) samples.
Choosing \(N\) such that the target tone lands near an integer bin minimises spectral leakage. A mismatch of half a bin can reduce the apparent magnitude by several decibels. Window functions do not apply here because the Goertzel filter already selects one bin; applying a window changes the effective DFT kernel.
The DC bin (\(k = 0\)) and the Nyquist bin (\(k = N/2\)) are purely real; their imaginary output is zero by symmetry.
The magnitude-squared form \(s_1^2 + s_2^2
- c\,s_1 s_2\) avoids the square root when only power detection
(threshold comparison) is needed, saving a transcendental call on
platforms without hardware sqrt.
When several tones must be tracked simultaneously, one independent Goertzel instance per tone runs in \(O(BN)\) total with \(O(B)\) state — still far cheaper than an FFT for small \(B\).
A sliding Goertzel variant updates one sample at a time by subtracting the oldest sample, giving a continuous output at the cost of maintaining an input ring buffer; this trades \(O(1)\) memory for \(O(N)\) memory and is outside the scope of the no-heap constraint here.
The Goertzel algorithm computes exactly the same result as one bin of
the DFT computed by the Cooley-Tukey FFT
(FastFourierTransform), but limits the computation to that
single bin. When all \(N\) bins are
required simultaneously, the FFT is always faster.
PowerDensitySpectrum builds on the FFT for broadband
spectral estimation. SignalDetectors provides simpler,
non-frequency-selective detectors (peak hold, RMS) that cost even less
per sample.
Every real-valued signal contains both positive and negative frequency components that carry redundant information. The Hilbert transform discards the negative half and pairs the original signal with a version shifted by exactly −90° at every frequency, producing a complex analytic signal whose magnitude tracks instantaneous amplitude and whose angle tracks instantaneous phase.
This is the standard technique for AM/DSB demodulation, vibration envelope detection, bearing-fault analysis, single-sideband modulation, and instantaneous-frequency measurement — all tasks where the underlying amplitude or phase varies slowly relative to a carrier and must be tracked in real time.
For a real signal \(x(t)\) the Hilbert transform is the convolution
\[\mathcal{H}\{x\}(t) = \frac{1}{\pi} \, \text{p.v.} \int_{-\infty}^{\infty} \frac{x(\tau)}{t - \tau} \, d\tau\]
which is equivalently a multiplication of each frequency component by \(-j \operatorname{sgn}(f)\), i.e. a \(-90°\) phase rotation for positive frequencies and \(+90°\) for negative.
The analytic signal is
\[z(t) = x(t) + j\,\mathcal{H}\{x\}(t)\]
Its one-sided spectrum satisfies \(Z(f) = 0\) for \(f < 0\), \(Z(0) = X(0)\), and \(Z(f) = 2X(f)\) for \(f > 0\).
| Quantity | Formula |
|---|---|
| Amplitude (envelope) | \(A(t) = | z(t) | = \sqrt{x^2 + \mathcal{H}^2\{x\}}\) |
| Phase | \(\phi(t) = \arg z(t) = \operatorname{atan2}(\mathcal{H}\{x\}, x)\) |
| Frequency | \(f_i(t) = \frac{1}{2\pi}\frac{d\phi}{dt}\) (phase unwrapped before differencing) |
Given the \(N\)-point DFT \(X[k]\) of a real sequence, the analytic signal is recovered by:
\[H[k] = \begin{cases} X[0] & k = 0 \\ 2X[k] & 1 \le k < N/2 \\ X[N/2] & k = N/2 \\ 0 & N/2 < k < N \end{cases}\]
followed by the inverse DFT of \(H\).
A Type III/IV antisymmetric FIR filter with impulse response
\[h[k] = \frac{2}{\pi k} w[k], \quad k \text{ odd}; \quad h[k] = 0, \quad k \text{ even}\]
(with a Hamming window \(w[k]\)) approximates the ideal \(-90°\) phase shift across its passband. The delayed original signal (center-tap copy) is paired with the filtered output to form the approximate analytic signal.
| Method | Time per call | Space | Notes |
|---|---|---|---|
| FFT (block) | \(O(N \log N)\) | \(2N\) complex words | Exact, latency \(N\) |
| FIR (streaming) | \(O(P)\) per sample | \(P\) words state | Approx., latency \((P-1)/2\) |
| Feature extraction | \(O(1)\) | None | atan2, sqrt, subtract |
\(P\) = number of FIR taps.
FFT method on \(N = 8\), input \(x = \cos(2\pi n / 8)\):
FastFourierTransform /
FastFourierTransformRadix2Impl as the block back-end.RealFastFourierTransform is an alternative back-end for
purely real inputs.ConvolutionCorrelation provides the FIR convolution
primitive used by the streaming path.SignalDetectors (RMS envelope, peak hold) offers
cheaper non-coherent envelope estimation.Two signals interacting in time — one being filtered, delayed, or matched against another — are described by convolution and correlation. Convolution is the time-domain action of any linear time-invariant (LTI) filter: given an input signal and a system’s impulse response, their convolution is the output. Correlation measures the similarity between two signals as a function of relative shift (lag), making it the foundation of matched filtering, pulse detection, and time-delay estimation.
Both operations are indispensable in embedded DSP: they underlie FIR filtering, pitch detection, radar/sonar processing, and auto-regressive modeling. Bounded, no-heap implementations make these algorithms practical on microcontrollers.
For finite sequences \(x\) of length \(M\) and \(h\) of length \(K\), the linear convolution \(y = x * h\) is a sequence of length \(M + K - 1\) defined by:
\[y[n] = \sum_{k=0}^{M-1} x[k] \cdot h[n - k], \quad n = 0, \ldots, M+K-2\]
where \(h[j] = 0\) for \(j < 0\) or \(j \geq K\).
For two sequences of equal length \(N\), circular (periodic) convolution wraps indices modulo \(N\):
\[y[n] = \sum_{k=0}^{N-1} x[k] \cdot h[(n - k) \bmod N], \quad n = 0, \ldots, N-1\]
Circular convolution equals linear convolution only when both operands are zero-padded to at least \(M + K - 1\) samples. Without padding, time-aliasing occurs.
Cross-correlation of \(x\) (length \(M\)) and \(y\) (length \(K\)) is defined as:
\[r_{xy}[\ell] = \sum_{n} x[n] \cdot y[n - \ell]\]
This equals linear convolution of \(x\) with the time-reversed \(y\):
\[r_{xy} = x * \overline{y}\]
Auto-correlation is cross-correlation of a signal with itself:
\[r_{xx}[\ell] = \sum_{n} x[n] \cdot x[n - \ell]\]
It is always symmetric about the zero-lag index, and its maximum occurs at zero lag. Dividing by \(r_{xx}[0]\) yields a normalized coefficient in \([-1, 1]\).
For long signals, the convolution theorem enables an \(O(L \log L)\) computation:
\[x * h = \mathcal{F}^{-1}\!\left(\mathcal{F}(x) \cdot \mathcal{F}(h)\right)\]
where \(\mathcal{F}\) denotes the DFT and \(L\) is the next power of two \(\geq M + K - 1\). The pointwise complex product replaces the \(O(MK)\) direct sum.
| Operation | Time | Space (extra) | Notes |
|---|---|---|---|
| Linear convolution | \(O(MK)\) | \(O(1)\) | Writes into caller-owned buffer |
| Circular convolution | \(O(N^2)\) | \(O(1)\) | Same as linear for equal-length input |
| Cross-correlation | \(O(MK)\) | \(O(K)\) | Reverses one operand on the stack |
| Auto-correlation | \(O(M^2)\) | \(O(1)\) | Alias of cross-correlation |
| Fast convolution | \(O(L \log L)\) | \(O(L)\) | \(L = 2^{\lceil\log_2(M+K-1)\rceil}\) |
The fast path is beneficial when \(MK > L \log_2 L\), roughly when both operands exceed 32–64 samples.
Linear convolution of \(x = [1, 2, 3]\) and \(h = [1, 1]\) (output length \(= 4\)):
| \(n\) | Active \(k\) range | Computation | \(y[n]\) |
|---|---|---|---|
| 0 | \(k=0\) | \(1 \cdot 1\) | 1 |
| 1 | \(k=0,1\) | \(2 \cdot 1 + 1 \cdot 1\) | 3 |
| 2 | \(k=1,2\) | \(3 \cdot 1 + 2 \cdot 1\) | 5 |
| 3 | \(k=2\) | \(3 \cdot 1\) | 3 |
Result: \([1, 3, 5, 3]\).
Auto-correlation of \([1, 1, 1, 1]\) produces the triangular sequence \([1, 2, 3, 4, 3, 2, 1]\), with the peak at the zero-lag center index (index 3 in 0-based notation).
ArgMaxLag returns the
index of the maximum in the cross-correlation output. For
cross-correlation of two length-\(M\)
sequences the zero-lag corresponds to index \(M - 1\); a delay of \(d\) samples appears at index \(M - 1 + d\).AutoCorrelation.The frequency response \(H(e^{j\omega})\) characterizes how a discrete-time linear system (filter) modifies the amplitude and phase of each frequency component in a signal. Computing and plotting the frequency response (Bode plot) is essential for verifying filter designs, understanding system behavior, and tuning parameters. This implementation evaluates the transfer function at logarithmically spaced frequencies, producing magnitude (dB) and phase (degrees) curves.
Given a filter with feedforward coefficients \(\{b_i\}_{i=0}^{P-1}\) and feedback coefficients \(\{a_i\}_{i=0}^{Q-1}\), the frequency response at angular frequency \(\omega\) is:
\[H(e^{j\omega}) = \frac{\sum_{i=0}^{P-1} b_i \cdot e^{-j\omega i}}{\sum_{i=0}^{Q-1} a_i \cdot e^{-j\omega i}}\]
For FIR filters, the denominator reduces to \(a_0 = 1\).
From the complex-valued response:
\[|H(e^{j\omega})| = \sqrt{\text{Re}(H)^2 + \text{Im}(H)^2}\]
\[\text{Magnitude (dB)} = 20 \log_{10} |H(e^{j\omega})|\]
\[\text{Phase (degrees)} = \frac{180}{\pi} \cdot \arg(H(e^{j\omega}))\]
Frequencies are evaluated on a logarithmic grid from \(f_s / N\) to \(f_s / 2\) (Nyquist), where \(f_s\) is the sampling frequency and \(N\) is the number of evaluation points. The angular frequency at each point is:
\[\omega_k = \frac{2\pi f_k}{f_s}\]
| Case | Time | Space | Notes |
|---|---|---|---|
| Average | \(O(N \cdot (P+Q))\) | \(O(N)\) | \(N\) frequency points, \(P+Q\) coefficients |
Given a simple first-order low-pass FIR filter with \(b = [0.5, 0.5]\), \(a = [1.0]\), and \(f_s = 100\) Hz, evaluated at \(f = 10\) Hz:
math::ToFloat() before evaluation. This ensures sufficient
dynamic range for the complex arithmetic.std::arg function
returns phase in \((-\pi, \pi]\). Phase
unwrapping is not performed.Fir and
Iir filters.When designing a feedback control system, a fundamental question is: how do the closed-loop poles move as I change the loop gain? The root locus answers this by plotting the trajectories of the closed-loop poles in the complex plane as a scalar gain \(K\) varies from \(K_{\min}\) to \(K_{\max}\).
This visualization immediately reveals whether increasing gain will drive the system unstable (poles crossing into the right half-plane), where oscillatory modes appear (complex pole pairs), and which gain ranges produce acceptable damping. It is one of the oldest and most intuitive tools in classical control design.
Given an open-loop transfer function:
\[G(s) = K \cdot \frac{N(s)}{D(s)}\]
the closed-loop characteristic equation (for unity feedback) is:
\[D(s) + K \cdot N(s) = 0\]
The root locus is the set of all roots of this equation as \(K\) varies over \([K_{\min}, K_{\max}]\).
The gain is swept logarithmically to provide uniform resolution across decades:
\[K_i = 10^{\,\log_{10}(K_{\min}) \;+\; \frac{i}{N-1}\left(\log_{10}(K_{\max}) - \log_{10}(K_{\min})\right)}\]
where \(N\) is the number of gain steps and \(i \in [0, N-1]\).
At each gain step, the roots of \(D(s) + K_i \cdot N(s) = 0\) are found using the Durand-Kerner polynomial root-finder.
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(N_g \cdot n^2 \cdot k)\) | \(O(N_g \cdot n)\) | \(N_g\) = gain steps, \(n\) = polynomial order, \(k\) = Durand-Kerner iterations per step |
Why: At each of the \(N_g\) gain steps, a degree-\(n\) polynomial is solved via Durand-Kerner, which performs \(k\) iterations each costing \(O(n^2)\) (evaluating the polynomial and computing the denominator product for all \(n\) roots).
System: \(G(s) = K \cdot \frac{s + 1}{s(s + 2)}\), sweep \(K\) from 0.01 to 10.
Step 1 — Identify poles and zeros
Step 2 — Form characteristic polynomial at \(K = 1\)
\[D(s) + K \cdot N(s) = s^2 + 2s + 1 \cdot (s + 1) = s^2 + 3s + 1\]
Step 3 — Solve using Durand-Kerner:
\[s = \frac{-3 \pm \sqrt{9 - 4}}{2} = \frac{-3 \pm \sqrt{5}}{2} \approx -0.382,\; -2.618\]
Both poles are real and negative → system is stable at \(K = 1\).
Step 4 — Repeat for each gain step and plot all root positions in the complex plane.
Im(s)
|
| × zero (-1)
--●-----×------●--> Re(s)
0 -1 -2
pole pole
As \(K\) increases: the two poles approach each other on the real axis, meet between 0 and −2, then split into a complex conjugate pair. One branch eventually converges to the zero at \(s = -1\); the other diverges to \(-\infty\).
| Variant | Key Difference |
|---|---|
| Complementary root locus | Traces roots for \(K < 0\) (positive feedback) |
| Root contour | Varies two or more parameters simultaneously |
| Discrete root locus | Same concept applied to \(z\)-domain polynomials for digital control |
| Evans rules | Analytical rules for sketching the root locus by hand (angle/magnitude criteria) |
graph LR
RL["Root Locus"] --> DK["Durand-Kerner"]
RL --> LQR["LQR (pole placement alternative)"]
DK --> RL
| Algorithm | Relationship |
|---|---|
| Durand-Kerner | Used at each gain step to find the roots of the characteristic polynomial |
| LQR | Alternative approach to pole placement — LQR optimizes a cost rather than manually selecting gain via root locus |
Controllability and observability are fundamental structural properties of a linear dynamical system. They answer two essential design questions: can the inputs drive the state to any desired configuration, and can the initial state be inferred from the outputs alone?
A system lacking controllability has modes that no actuator can excite; a system lacking observability has modes that no sensor can detect. Both deficiencies make feedback design either impossible or degenerate. Checking these properties before attempting pole placement, LQR synthesis, or observer design prevents numerical failure and guides sensor-actuator placement.
Consider a discrete-time linear time-invariant system
\[x_{k+1} = A x_k + B u_k, \quad y_k = C x_k + D u_k\]
with state dimension \(n\), input dimension \(m\), and output dimension \(p\).
The \(n \times nm\) controllability matrix is
\[\mathcal{C} = \begin{bmatrix} B & AB & A^2 B & \cdots & A^{n-1}B \end{bmatrix}\]
The pair \((A, B)\) is controllable if and only if \(\mathrm{rank}(\mathcal{C}) = n\).
The \(np \times n\) observability matrix is
\[\mathcal{O} = \begin{bmatrix} C \\ CA \\ CA^2 \\ \vdots \\ CA^{n-1} \end{bmatrix}\]
The pair \((A, C)\) is observable if and only if \(\mathrm{rank}(\mathcal{O}) = n\).
Controllability and observability are dual: \((A, B)\) is controllable if and only if \((A^\top, B^\top)\) is observable. Equivalently, \(\mathcal{C}(A, B) = \mathcal{O}(A^\top, C^\top)^\top\).
For a Schur-stable matrix \(A\) (spectral radius \(< 1\)), the controllability and observability Gramians are the unique positive semi-definite solutions to the discrete Lyapunov equations
\[A W_c A^\top - W_c + BB^\top = 0, \quad A^\top W_o A - W_o + C^\top C = 0.\]
The Gramians encode more than rank: their eigenvalues quantify how easily each mode is excited or observed. Balanced truncation model reduction uses Gramians to identify modes that are simultaneously hard to reach and hard to see.
Rank is computed by Gaussian elimination with partial pivoting on a working copy of the matrix. At each step the column with the largest absolute pivot is selected among remaining rows. A pivot is counted only if its magnitude exceeds \(\tau \cdot \|M\|_\infty\) where \(\tau\) is a user-supplied tolerance.
| Operation | Time | Space | Notes |
|---|---|---|---|
| Controllability matrix | \(O(n^2 m)\) per column, \(n\) columns → \(O(n^3 m)\) | \(O(nm)\) output | Iterative matmul |
| Observability matrix | \(O(n^2 p)\) per row-block, \(n\) blocks → \(O(n^3 p)\) | \(O(np)\) output | Iterative matmul |
| Rank (Gaussian elim) | \(O(r^2 \min(r,c))\) where \(r,c\) are matrix dims | \(O(rc)\) copy | Partial pivoting |
| Gramian (Lyapunov fixed-point) | \(O(K n^3)\) iterations | \(O(n^2)\) | Up to 200 iterations |
Plant: \(A = \begin{bmatrix}0 & 1 \\ -2 & -3\end{bmatrix}\), \(B = \begin{bmatrix}0 \\ 1\end{bmatrix}\), \(C = \begin{bmatrix}1 & 0\end{bmatrix}\).
Controllability matrix:
\(AB = \begin{bmatrix}0 & 1 \\ -2 & -3\end{bmatrix}\begin{bmatrix}0 \\ 1\end{bmatrix} = \begin{bmatrix}1 \\ -3\end{bmatrix}\)
\(\mathcal{C} = \begin{bmatrix}0 & 1 \\ 1 & -3\end{bmatrix}\), \(\det(\mathcal{C}) = -1 \neq 0\), rank \(= 2\). Controllable.
Observability matrix:
\(CA = \begin{bmatrix}1 & 0\end{bmatrix}\begin{bmatrix}0 & 1 \\ -2 & -3\end{bmatrix} = \begin{bmatrix}0 & 1\end{bmatrix}\)
\(\mathcal{O} = \begin{bmatrix}1 & 0 \\ 0 & 1\end{bmatrix}\), rank \(= 2\). Observable.
Physical plants and classical controllers are designed in continuous time using differential equations and the Laplace transform. Microcontrollers, however, execute discrete update loops at a fixed sampling period \(T_s\). Continuous-to-discrete conversion bridges the two worlds: it takes a continuous-time state-space model \((A, B, C, D)\) and produces the equivalent discrete-time model \((A_d, B_d, C_d, D_d)\) that a sampled-data controller can execute directly.
Choosing the right discretization method is critical. A poor choice can push stable poles outside the unit disk, introduce spurious resonances, or distort the DC gain — all of which degrade closed-loop performance or cause instability on hardware.
A continuous-time LTI system obeys
\[\dot{x}(t) = A\,x(t) + B\,u(t), \qquad y(t) = C\,x(t) + D\,u(t)\]
where \(x \in \mathbb{R}^n\) is the state, \(u \in \mathbb{R}^m\) the input, and \(y \in \mathbb{R}^p\) the output. The goal is to find \((A_d, B_d, C_d, D_d)\) such that
\[x_{k+1} = A_d\,x_k + B_d\,u_k, \qquad y_k = C_d\,x_k + D_d\,u_k\]
matches the continuous solution at sample instants \(t_k = k\,T_s\).
Assuming the input is held constant between samples (true for a DAC or PWM), the exact solution is
\[A_d = e^{A T_s}, \qquad B_d = \left(\int_0^{T_s} e^{A\tau}\,d\tau\right) B\]
Van Loan (1978) observed that both quantities emerge from a single matrix exponential of the augmented \((n+m) \times (n+m)\) block matrix:
\[M = \begin{bmatrix} A & B \\ 0 & 0 \end{bmatrix} T_s, \qquad e^M = \begin{bmatrix} A_d & B_d \\ 0 & I \end{bmatrix}\]
The upper-left \(n\times n\) block is \(A_d\); the upper-right \(n\times m\) block is \(B_d\). \(C\) and \(D\) are unchanged under ZOH because the output equation is instantaneous.
The Tustin method replaces the Laplace variable via
\[s \;\longleftarrow\; \frac{2}{T_s}\,\frac{z-1}{z+1}\]
Setting \(\alpha = 2/T_s\) and \(P = (\alpha I - A)^{-1}\):
\[A_d = P(\alpha I + A), \quad B_d = 2P\,B, \quad C_d = \alpha\,C\,P, \quad D_d = D + C\,P\,B\]
Tustin maps the entire open left-half plane into the open unit disk, so it is stability-preserving by construction. It is the preferred method for discretizing controllers and filters when a specific corner frequency must be matched (after frequency prewarping).
The cheapest approximation — first-order rectangle rule:
\[A_d = I + A\,T_s, \quad B_d = B\,T_s, \quad C_d = C, \quad D_d = D\]
Conditionally stable: poles may leave the unit disk when \(T_s\) is too large relative to the fastest mode.
Implicit first-order rule, equivalent to the \(s \leftarrow (z-1)/(z\,T_s)\) substitution. Setting \(P = (I - A\,T_s)^{-1}\):
\[A_d = P, \quad B_d = P\,B\,T_s, \quad C_d = C\,P, \quad D_d = D + C\,P\,B\,T_s\]
Unconditionally stable (maps the left-half plane into the unit disk) but introduces phase lag relative to the true system.
| Method | Time | Space | Notes |
|---|---|---|---|
| ZOH | \(O((n+m)^3)\) | \((n+m)^2\) augmented mat | One matrix exponential via scaling/squaring |
| Tustin | \(O(n^3)\) | \(O(n^2)\) | One \(n \times n\) LU factorisation |
| Forward Euler | \(O(n^2)\) | \(O(n^2)\) | Pure matrix multiply + add |
| Backward Euler | \(O(n^3)\) | \(O(n^2)\) | One \(n \times n\) LU factorisation |
All working memory is allocated on the stack; no heap is used.
Consider a scalar integrator \(\dot{x} = u\), so \(A=0\), \(B=1\), \(C=1\), \(D=0\), with \(T_s = 0.1\).
ZOH:
\[M = \begin{bmatrix} 0 & 1 \\ 0 & 0 \end{bmatrix} \times 0.1 = \begin{bmatrix} 0 & 0.1 \\ 0 & 0 \end{bmatrix}\]
\[e^M = I + M = \begin{bmatrix} 1 & 0.1 \\ 0 & 1 \end{bmatrix}\]
So \(A_d = 1\), \(B_d = 0.1\) — the integrator accumulates \(u \cdot T_s\) each step.
Tustin (\(\alpha = 20\)):
\[P = (20 - 0)^{-1} = 0.05\]
\[A_d = 0.05 \times 20 = 1, \quad B_d = 2 \times 0.05 = 0.1\]
Both methods agree exactly for a pure integrator, as the bilinear transform maps the pole at \(s=0\) to \(z=1\) without distortion.
expm of the Van Loan augmented block; the
exponential accuracy directly sets the ZOH accuracy.LinearTimeInvariant structs; the discrete model drops
straight into the state-update loop.Classical control design and modern state-space methods speak different mathematical languages. Frequency-domain tools — Bode plots, root locus, PID/lead-lag synthesis — operate on transfer functions, ratios of polynomials in the Laplace variable \(s\). Modern methods — LQR, observers, Kalman filters — require the state-space quadruple \((A, B, C, D)\). This converter is the bridge. A compensator designed in the frequency domain can be dropped into a state-space runtime without hand-derivation, and a state-space plant model can be lifted into the frequency domain for classical analysis.
A SISO rational transfer function is
\[H(s) = \frac{b_0 s^n + b_1 s^{n-1} + \cdots + b_n}{a_0 s^n + a_1 s^{n-1} + \cdots + a_n}\]
Normalising by \(a_0\) produces the monic denominator \(s^n + \hat{a}_1 s^{n-1} + \cdots + \hat{a}_n\). When \(\deg(\text{num}) = \deg(\text{den})\), a polynomial division peels off the direct feed-through scalar \(D = b_0/a_0\), leaving a strictly proper remainder.
The monic denominator maps directly to the companion (controllable canonical) matrix
\[A = \begin{bmatrix} 0 & 1 & 0 & \cdots & 0 \\ 0 & 0 & 1 & \cdots & 0 \\ \vdots & & & \ddots & \vdots \\ -\hat{a}_n & -\hat{a}_{n-1} & \cdots & & -\hat{a}_1 \end{bmatrix}, \quad B = \begin{bmatrix} 0 \\ \vdots \\ 0 \\ 1 \end{bmatrix}\]
The output row \(C = \begin{bmatrix} \hat{b}_n' & \cdots & \hat{b}_1' \end{bmatrix}\) is formed from the strictly-proper remainder coefficients \(\hat{b}_i'\) after the feed-through split.
The observable canonical form is the algebraic dual of the controllable form:
\[A_\text{ocf} = A_\text{ccf}^\top, \quad B_\text{ocf} = C_\text{ccf}^\top, \quad C_\text{ocf} = B_\text{ccf}^\top, \quad D_\text{ocf} = D_\text{ccf}\]
Both realisations represent the same input-output map and share identical transfer functions.
Given \((A, B, C, D)\), the transfer function is recovered via
\[H(s) = C\,(sI - A)^{-1} B + D = \frac{C\,\text{adj}(sI - A)\,B + D\,\det(sI - A)}{\det(sI - A)}\]
The Faddeev–Le Verrier algorithm computes the characteristic polynomial \(\det(sI - A) = s^n + c_1 s^{n-1} + \cdots + c_n\) and the adjugate action \(\text{adj}(sI - A)B\) in a single recursion of \(n\) steps:
\[M_0 = I, \quad c_k = -\frac{1}{k}\mathrm{tr}(A M_{k-1}), \quad M_k = A M_{k-1} + c_k I\]
The numerator coefficient for degree \(n - k\) is \(C M_k B\).
| Operation | Time | Space | Notes |
|---|---|---|---|
ToControllableCanonical |
\(O(n)\) | \(O(n^2)\) | Direct coefficient placement |
ToObservableCanonical |
\(O(n^2)\) | \(O(n^2)\) | One transpose after CCF |
ToTransferFunction |
\(O(n^4)\) | \(O(n^2)\) | \(n\) steps, each \(O(n^3)\) matrix-matrix |
Memory is \(O(n^2)\) for \(A\) and \(O(n)\) for coefficient arrays; all
allocations are std::array on the stack.
\(H(s) = \dfrac{s + 2}{s^2 + 3s + 2}\), so \(n = 2\), denominator \([1, 3, 2]\), numerator \([0, 1, 2]\).
Monic normalisation: already monic; \(\hat{a}_1 = 3\), \(\hat{a}_2 = 2\).
Feed-through split: \(b_0 = 0\), so \(D = 0\) and \(\hat{b}' = [0, 1, 2]\).
Companion matrix:
\[A = \begin{bmatrix} 0 & 1 \\ -2 & -3 \end{bmatrix}, \quad B = \begin{bmatrix} 0 \\ 1 \end{bmatrix}, \quad C = \begin{bmatrix} 2 & 1 \end{bmatrix}\]
Observable form:
\[A_\text{ocf} = \begin{bmatrix} 0 & -2 \\ 1 & -3 \end{bmatrix}, \quad B_\text{ocf} = \begin{bmatrix} 2 \\ 1 \end{bmatrix}, \quad C_\text{ocf} = \begin{bmatrix} 0 & 1 \end{bmatrix}\]
Round-trip verification via Le Verrier (\(n = 2\)):
Result: \(H(s) = \dfrac{s + 2}{s^2 + 3s + 2}\). Identical to the original.
LinearTimeInvariant runtime for embedded execution.LinearTimeInvariant: the target
realisation type; canonical forms drop directly into its
Step/Output interface.ControllabilityObservability: a
canonical realisation is minimal if and only if it is both controllable
and observable; use these checks after conversion.FrequencyResponse /
RootLocus: classical analyses that accept
transfer-function coefficients; feed the recovered transfer function
from ToTransferFunction directly.DurandKerner: factors the denominator
polynomial into its roots (poles); combine with this converter for
pole-zero plots.The bang-bang controller is the simplest closed-loop regulator: its output switches between two discrete levels depending on whether the controlled variable is above or below a threshold. Without hysteresis, a plain threshold comparator chatters — switching at high frequency whenever noise nudges the signal across the boundary.
Adding a dead-band (Schmitt trigger) eliminates chatter by giving the relay memory: once the output goes High it stays High until the measurement falls all the way to a lower threshold, and vice versa. This makes the relay a practical actuator-safe control primitive for thermostats, fridge compressors, tank level switches, and power-stage on/off regulation.
The relay holds a binary state \(s \in \{\text{Low}, \text{High}\}\) with the following transition rules:
\[ s[k] = \begin{cases} \text{High} & \text{if } s[k-1] = \text{Low} \text{ and } x[k] \geq \theta_H \\ \text{Low} & \text{if } s[k-1] = \text{High} \text{ and } x[k] \leq \theta_L \\ s[k-1] & \text{otherwise} \end{cases} \]
where \(\theta_L < \theta_H\) are the lower and upper switching thresholds (the hysteresis band).
\[ u[k] = \begin{cases} u_H & \text{if } s[k] = \text{High} \\ u_L & \text{if } s[k] = \text{Low} \end{cases} \]
The output levels \(u_L\) and \(u_H\) are arbitrary; common choices are \(\{0, 1\}\) or \(\{-1, +1\}\).
The band width \(\Delta = \theta_H - \theta_L\) is the key design parameter. It bounds the switching frequency \(f_s\) given a signal slope \(\dot{x}\):
\[ f_s \leq \frac{|\dot{x}|}{2\Delta} \]
A wider band reduces \(f_s\) (protecting relays and power stages) at the cost of a larger steady-state limit cycle amplitude.
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(1)\) | \(O(1)\) | Two comparisons, one state bit, one select |
No arithmetic on the signal path — only comparisons — so the relay introduces no numerical error and is exactly representable in any floating-point format.
Setup: band \([\theta_L, \theta_H] = [-0.2, 0.2]\), outputs \(u_L = 0\), \(u_H = 1\), initial state Low.
| Step | \(x[k]\) | Condition | \(s[k]\) | \(u[k]\) |
|---|---|---|---|---|
| 1 | 0.0 | Low, \(x < 0.2\) | Low | 0 |
| 2 | 0.3 | Low, \(x \geq 0.2\) → switch | High | 1 |
| 3 | 0.1 | High, \(x > -0.2\) → hold | High | 1 |
| 4 | −0.3 | High, \(x \leq -0.2\) → switch | Low | 0 |
| 5 | 0.0 | Low, \(x < 0.2\) → hold | Low | 0 |
| Variant | Key Difference |
|---|---|
| Plain comparator | \(\Delta = 0\); no memory, chatters on noise |
| Asymmetric band | \(\theta_H\) and \(\theta_L\) not symmetric around the set-point; biases the duty cycle |
| Three-state relay | Adds a dead-band output level \(u_0\); used in motor direction control |
| Adaptive hysteresis | Band width tracks signal variance online to maintain a target switching rate |
| Relay feedback test (Åström–Hägglund) | Deliberate oscillation under relay feedback to identify the ultimate gain/period for automatic PID tuning |
| Algorithm | Relationship |
|---|---|
| PID Controller | The relay’s limit cycle can be used to identify PID tuning parameters via the Åström–Hägglund relay-feedback test |
| Saturation / Rate Limiter | Continuous-output counterpart for actuator constraint; often combined with a relay in cascaded loops |
The Proportional-Integral-Derivative (PID) controller is the workhorse of industrial feedback control. It continuously computes an error \(e(t)\) — the difference between a desired setpoint and a measured process variable — and applies a correction based on three terms: one proportional to the current error, one to its accumulated history, and one to its rate of change.
Despite its simplicity, PID handles a remarkably wide range of plants — from temperature regulation to motor speed control — because each term addresses a different aspect of the transient response:
This library implements a discrete recursive form suitable for fixed-sample-rate embedded loops, where the output is computed incrementally rather than from scratch at every step.
\[u(t) = K_p \, e(t) + K_i \int_0^t e(\tau)\,d\tau + K_d \frac{de(t)}{dt}\]
Discretizing with sampling period \(T_s\) and applying the difference approximation:
\[u[n] = u[n-1] + a_0 \, e[n] + a_1 \, e[n-1] + a_2 \, e[n-2]\]
where:
\[a_0 = K_p + K_i + K_d\] \[a_1 = -(K_p + 2K_d)\] \[a_2 = K_d\]
This incremental (velocity) form has two key advantages:
The output is clamped to \([u_{\min}, u_{\max}]\) after each computation, which also provides implicit anti-windup: the integral term cannot drive the output beyond the saturation limits.
| Case | Time | Space | Notes |
|---|---|---|---|
| Per sample | \(O(1)\) | \(O(1)\) | 3 multiplications, 3 additions, 1 clamp |
The algorithm uses a fixed-size recursive buffer storing \(e[n-1]\), \(e[n-2]\), and \(u[n-1]\). No loops, no dynamic allocation, fully deterministic execution time.
Parameters: \(K_p = 2\), \(K_i = 0.5\), \(K_d = 0.1\), limits \([-10, 10]\), setpoint \(= 5\).
Coefficients: - \(a_0 = 2 + 0.5 + 0.1 = 2.6\) - \(a_1 = -(2 + 0.2) = -2.2\) - \(a_2 = 0.1\)
| Step | Measured | \(e[n]\) | \(e[n-1]\) | \(e[n-2]\) | \(\Delta u\) | \(u[n]\) (clamped) |
|---|---|---|---|---|---|---|
| 0 | 0.0 | 5.0 | 0 | 0 | \(2.6 \times 5 = 13\) | 10.0 (clamped) |
| 1 | 2.0 | 3.0 | 5.0 | 0 | \(2.6(3) - 2.2(5) + 0.1(0) = -3.2\) | 6.8 |
| 2 | 4.0 | 1.0 | 3.0 | 5.0 | \(2.6(1) - 2.2(3) + 0.1(5) = -3.5\) | 3.3 |
| 3 | 4.8 | 0.2 | 1.0 | 3.0 | \(2.6(0.2) - 2.2(1) + 0.1(3) = -1.38\) | 1.92 |
The output converges toward the steady-state value needed to maintain the setpoint.
Reset() after
large setpoint changes to clear stale error history.| Variant | Key Difference |
|---|---|
| Standard (positional) PID | Computes \(u[n]\) from scratch each step using an explicit integral accumulator |
| PI controller | \(K_d = 0\); simpler, no derivative noise issues |
| PD controller | \(K_i = 0\); no steady-state error correction, used when offset is acceptable |
| PID with derivative filter | Low-passes the derivative term to reject high-frequency noise |
| Gain-scheduled PID | Tuning parameters vary as a function of operating point |
| Cascade PID | Inner and outer loops, each with its own PID — common in motor control |
graph LR
PID["PID Controller"]
LQR["LQR Controller"]
KF["Kalman Filter"]
PID -.->|"alternative"| LQR
KF -->|"state estimate"| LQR
KF -->|"filtered measurement"| PID
| Algorithm | Relationship |
|---|---|
| LQR Controller | Optimal alternative when a state-space model is available; PID is model-free. |
| Kalman Filter | Can provide filtered state estimates as input to either PID or LQR |
When a state-space model of the plant is available, the Linear Quadratic Regulator provides the mathematically optimal state-feedback gain. Instead of manually tuning three knobs (as with PID), LQR asks a more principled question: given how much I care about state deviation versus control effort, what is the cheapest way to drive the state to zero?
The answer is a constant gain matrix \(K\) such that the control law \(u[k] = -Kx[k]\) minimizes an infinite-horizon quadratic cost. The elegance of LQR is that this problem has a closed-form solution through the Discrete Algebraic Riccati Equation (DARE).
For embedded systems, the gain \(K\) can be computed offline and stored as a constant — the runtime cost is then a single matrix-vector multiplication per control step.
\[x[k+1] = Ax[k] + Bu[k]\]
where \(x \in \mathbb{R}^n\) is the state, \(u \in \mathbb{R}^m\) is the control input, \(A\) is the state transition matrix, and \(B\) is the input matrix.
The LQR minimizes the infinite-horizon quadratic cost:
\[J = \sum_{k=0}^{\infty} \left( x[k]^T Q \, x[k] + u[k]^T R \, u[k] \right)\]
where: - \(Q \succeq 0\) (positive semi-definite) penalizes state deviation. - \(R \succ 0\) (positive definite) penalizes control effort.
The solution requires finding the matrix \(P\) that satisfies the Discrete Algebraic Riccati Equation:
\[P = A^T P A - A^T P B \left(R + B^T P B\right)^{-1} B^T P A + Q\]
The optimal gain is then:
\[K = \left(R + B^T P B\right)^{-1} B^T P A\]
and the optimal control law is \(u[k] = -Kx[k]\).
A unique stabilizing solution \(P\) exists when: 1. \((A, B)\) is stabilizable (all unstable modes are controllable). 2. \((A, C)\) is detectable (where \(Q = C^T C\)).
\[Q_{ii} = \frac{1}{(\text{max acceptable } x_i)^2}, \qquad R_{jj} = \frac{1}{(\text{max acceptable } u_j)^2}\]
| Phase | Time | Space | Notes |
|---|---|---|---|
| DARE solve (offline) | \(O(I \cdot n^3)\) | \(O(n^2)\) | \(I\) iterations, each involving \(n \times n\) matrix operations |
| Control step (online) | \(O(n \cdot m)\) | \(O(n \cdot m)\) | Single matrix-vector multiply \(u = -Kx\) |
The DARE iteration is the expensive part, but it is done once (offline or at initialization). The per-sample cost is just a matrix-vector product.
System: Double integrator with \(\Delta t = 0.01\)
\[A = \begin{bmatrix} 1 & 0.01 \\ 0 & 1 \end{bmatrix}, \quad B = \begin{bmatrix} 0.00005 \\ 0.01 \end{bmatrix}\]
\[Q = \begin{bmatrix} 100 & 0 \\ 0 & 1 \end{bmatrix}, \quad R = [1]\]
Step 1 — Initialize \(P_0 = Q\)
Step 2 — DARE iteration (showing first iteration):
Step 3 — Repeat until \(\|P_{k+1} - P_k\| < \varepsilon\) (typically 10–30 iterations).
Step 4 — Extract gain \(K = (R + B^T P B)^{-1} B^T P A\)
Runtime: \(u[k] = -K x[k]\) — a \(1 \times 2\) times \(2 \times 1\) multiply per sample.
| Variant | Key Difference |
|---|---|
| Continuous-time LQR | Solves the Continuous ARE instead of DARE; used for continuous-time plant models |
| LQG (Linear Quadratic Gaussian) | Combines LQR with Kalman filter for systems with noisy, partial observations |
| LQR with integral action | Augments the state with integral of tracking error to eliminate steady-state offset |
| Finite-horizon LQR | Time-varying gain \(K[k]\) for finite-duration tasks |
| Robust LQR (H∞) | Accounts for model uncertainty by optimizing the worst-case cost |
| Pre-computed gain | \(K\) is computed offline (e.g., in
MATLAB with dlqr) and hard-coded for embedded targets |
graph LR
LQR["LQR Controller"]
DARE["DARE Solver"]
GE["Gaussian Elimination"]
KF["Kalman Filter"]
PID["PID Controller"]
DARE --> LQR
GE --> DARE
KF -->|"state estimate"| LQR
PID -.->|"model-free alternative"| LQR
| Algorithm | Relationship |
|---|---|
| DARE Solver | Computes the cost-to-go matrix \(P\) from which \(K\) is derived |
| Gaussian Elimination | Used inside DARE iteration to solve linear sub-systems |
| Kalman Filter | Provides state estimates when direct measurement is unavailable (forming LQG) |
| PID Controller | Model-free alternative; simpler to deploy but not optimal |
Plain LQR drives the system state toward the origin but leaves a persistent steady-state offset when a constant reference or disturbance is present. The integral state feedback controller (LQI, or servo LQR) removes this offset by augmenting the plant with integrators on the tracking error. A single LQR solve on the augmented system produces two gains: one that acts on the physical states and one that closes the integral loop, guaranteeing zero steady-state error to constant references with no manual trim.
The discrete-time plant is
\[x_{k+1} = A x_k + B u_k, \quad y_k = C x_k\]
with state \(x \in \mathbb{R}^n\), input \(u \in \mathbb{R}^m\), and tracked output \(y \in \mathbb{R}^p\).
Define the integral-of-error state \(x_i \in \mathbb{R}^p\):
\[x_{i,k+1} = x_{i,k} + (r_k - y_k) T_s\]
Stacking \(\xi = [x^\top \; x_i^\top]^\top\) gives the augmented system
\[\xi_{k+1} = A_a \xi_k + B_a u_k + E_a r_k\]
\[A_a = \begin{bmatrix} A & 0 \\ -C T_s & I \end{bmatrix}, \quad B_a = \begin{bmatrix} B \\ 0 \end{bmatrix}, \quad E_a = \begin{bmatrix} 0 \\ T_s I \end{bmatrix}\]
Minimise the infinite-horizon quadratic cost
\[J = \sum_{k=0}^{\infty} \bigl(\xi_k^\top Q \xi_k + u_k^\top R u_k\bigr)\]
by solving the Discrete Algebraic Riccati Equation (DARE) for \(P\):
\[P = A_a^\top P A_a - A_a^\top P B_a (R + B_a^\top P B_a)^{-1} B_a^\top P A_a + Q\]
The optimal gain partitions as \(K_a = [K_x \mid K_i]\) where \(K_x \in \mathbb{R}^{m \times n}\) acts on the physical states and \(K_i \in \mathbb{R}^{m \times p}\) acts on the integral states.
\[u_k = -K_x x_k - K_i x_{i,k}\]
At equilibrium \(r - y = 0\), so \(x_{i}\) stops changing, and the control law holds \(y = r\) exactly.
| Case | Time | Space | Notes |
|---|---|---|---|
| Design | \(O((n+p)^3)\) | \(O((n+p)^2)\) | DARE iteration on augmented system |
| Update | \(O(m(n+p))\) | \(O(p)\) | Two matrix-vector products |
Design is a one-time offline computation. The per-sample update cost is dominated by the two gain-state products and the integral accumulation.
Consider a scalar plant (\(n=1\), \(m=1\), \(p=1\), \(T_s = 0.01\)):
Lqr on the augmented plant.Lqr.Lqr with a Kalman filter;
LQI could similarly pair with Lqg for output-feedback servo
control.The Linear Quadratic Gaussian (LQG) controller combines two classical optimal control results: the Linear Quadratic Regulator (LQR) and the Kalman Filter (KF). It is the optimal output-feedback controller for linear time-invariant (LTI) systems subject to Gaussian process and measurement noise.
While LQR assumes full state availability — a strict requirement rarely satisfied in practice — LQG relaxes this by estimating the state from noisy output measurements. The Kalman Filter provides the minimum-variance estimate, and LQR computes the optimal control law given that estimate. By the Separation Principle, these two subproblems can be solved independently and their solutions combined without loss of optimality.
LQG is widely used in aerospace guidance, robotics, and process control when sensor noise is substantial and the plant model is reasonably accurate.
Consider the discrete-time LTI system:
\[x_{k+1} = A x_k + B u_k + w_k\]
\[y_k = C x_k + v_k\]
where: - \(x_k \in \mathbb{R}^n\) is the state vector - \(u_k \in \mathbb{R}^m\) is the control input - \(y_k \in \mathbb{R}^p\) is the measurement vector - \(w_k \sim \mathcal{N}(0, Q_w)\) is process noise - \(v_k \sim \mathcal{N}(0, R_v)\) is measurement noise
The regulator minimises the infinite-horizon quadratic cost:
\[J = \sum_{k=0}^{\infty} \left( x_k^T Q x_k + u_k^T R u_k \right)\]
The optimal state-feedback gain is:
\[K = (R + B^T P B)^{-1} B^T P A\]
where \(P\) is the positive semi-definite solution to the Discrete Algebraic Riccati Equation (DARE):
\[P = Q + A^T P A - A^T P B (R + B^T P B)^{-1} B^T P A\]
The optimal control law is \(u_k = -K \hat{x}_{k|k}\).
The optimal state estimator propagates:
Predict: \[\hat{x}_{k+1|k} = A \hat{x}_{k|k} + B u_k\]
\[P_{k+1|k} = A P_{k|k} A^T + Q_w\]
Update: \[K_f = P_{k|k-1} C^T (C P_{k|k-1} C^T + R_v)^{-1}\]
\[\hat{x}_{k|k} = \hat{x}_{k|k-1} + K_f (y_k - C \hat{x}_{k|k-1})\]
\[P_{k|k} = (I - K_f C) P_{k|k-1} (I - K_f C)^T + K_f R_v K_f^T\]
The steady-state Kalman gain converges to a fixed matrix as \(k \to \infty\).
The fundamental result that makes LQG tractable is:
Theorem (Separation Principle): The optimal output-feedback control law for the LQG problem is the composition of (1) the optimal state estimator (Kalman Filter) and (2) the optimal state-feedback controller (LQR), designed independently.
This means the LQR gain \(K\) and the Kalman gain \(K_f\) do not interact — changing the noise covariances \(Q_w\), \(R_v\) affects only the estimator, and changing the cost weights \(Q\), \(R\) affects only the regulator.
At each time step \(k\):
| Step | Time | Space | Notes |
|---|---|---|---|
| Construction | \(O(n^3)\) | \(O(n^2)\) | DARE solve for LQR gain |
| ComputeControl | \(O(n^2 + nm)\) | \(O(1)\) | KF update + LQR multiply |
where \(n\) = state size, \(m\) = input size, \(p\) = measurement size.
float unless the target platform
lacks an FPU.Consider a double integrator plant — a mass subject to force input, observed through a noisy position sensor. The discrete-time matrices with sample period \(T_s = 0.1\,\text{s}\) are:
\[A = \begin{bmatrix} 1 & 0.1 \\ 0 & 1 \end{bmatrix}, \quad B = \begin{bmatrix} 0 \\ 0.1 \end{bmatrix}, \quad C = \begin{bmatrix} 1 & 0 \end{bmatrix}\]
Construction (offline):
Online loop (per sample):
| Step | Operation | Notes |
|---|---|---|
| 1 | Receive sensor measurement \(y_k\) | Noisy position reading |
| 2 | \(\hat{x}_{k\|k} = \hat{x}_{k\|k-1} + K_f(y_k - C\hat{x}_{k\|k-1})\) | Kalman update |
| 3 | \(u_k = -K \hat{x}_{k\|k}\) | LQR control law |
| 4 | \(\hat{x}_{k+1\|k} = A \hat{x}_{k\|k} + B u_k\) | Kalman predict |
Starting from initial position \(x_0 = [1, 0]^T\), the LQG regulator drives the position to zero while the Kalman Filter’s estimated trajectory converges to the true state despite measurement noise.
Model Predictive Control (MPC) is a receding-horizon optimal control strategy that computes control inputs by solving a finite-horizon optimization problem at each time step. Unlike LQR, which applies a fixed gain computed offline, MPC re-solves the optimization online, enabling it to handle constraints on control inputs directly.
At each sample instant, MPC predicts the future system trajectory over a prediction horizon \(N_p\), optimizes a sequence of control moves over a control horizon \(N_c \leq N_p\), applies only the first control move, and repeats at the next step. This receding-horizon approach provides feedback and robustness to disturbances.
Key advantages over LQR: - Explicit handling of input constraints (actuator limits) - Preview and anticipation of future reference changes - Tunable trade-off between horizon length and computational cost
When to use MPC: - Systems with actuator saturation or operational limits - Multi-input multi-output (MIMO) systems requiring coordinated control - Applications where the computational budget allows online optimization (typically \(N_c \cdot m \leq 20\))
Cost function over prediction horizon \(N_p\) with control horizon \(N_c\):
\[J = \sum_{k=0}^{N_p-1} x_k^T Q\, x_k + \sum_{k=0}^{N_c-1} u_k^T R\, u_k + x_{N_p}^T P\, x_{N_p}\]
For \(k \geq N_c\), the control input is held at \(u_{N_c-1}\) or zero (our implementation sets it to zero beyond \(N_c\)).
The future state trajectory can be expressed as:
\[\mathbf{X} = \Psi\, x_0 + \Theta\, \mathbf{U}\]
where \(\mathbf{X} = [x_1^T, x_2^T, \ldots, x_{N_p}^T]^T\) and \(\mathbf{U} = [u_0^T, u_1^T, \ldots, u_{N_c-1}^T]^T\).
State propagation matrix \(\Psi\) (\(N_p n \times n\)):
\[\Psi = \begin{bmatrix} A \\ A^2 \\ \vdots \\ A^{N_p} \end{bmatrix}\]
Control-to-state matrix \(\Theta\) (\(N_p n \times N_c m\)):
\[\Theta = \begin{bmatrix} B & 0 & \cdots & 0 \\ AB & B & \cdots & 0 \\ A^2B & AB & \cdots & 0 \\ \vdots & \vdots & \ddots & \vdots \\ A^{N_p-1}B & A^{N_p-2}B & \cdots & A^{N_p-N_c}B \end{bmatrix}\]
Substituting predictions into the cost yields a quadratic program in \(\mathbf{U}\):
\[J = \mathbf{U}^T H\, \mathbf{U} + 2\, x_0^T F^T \mathbf{U} + \text{const}\]
where:
\[H = \Theta^T \bar{Q}\, \Theta + \bar{R}\] \[F = \Theta^T \bar{Q}\, \Psi\]
\(\bar{Q}\) is block-diagonal with \(Q\) on the first \(N_p - 1\) blocks and \(P\) on the last block. \(\bar{R}\) is block-diagonal with \(R\) repeated \(N_c\) times.
Setting \(\nabla_{\mathbf{U}} J = 0\):
\[\mathbf{U}^* = -H^{-1} F\, x_0\]
Solved via Gaussian elimination (\(H\, \mathbf{U}^* = -F\, x_0\)) rather than explicit matrix inversion.
For control input constraints \(u_{\min} \leq u_k \leq u_{\max}\), a simple projected approach clamps each element of the unconstrained solution. This is a first-order approximation; for tight constraints or state constraints, a full QP solver would be required.
| Phase | Time | Space | Notes |
|---|---|---|---|
| Offline | \(O(N_p^2 \cdot n^3)\) | \(O((N_c m)^2 + N_p n \cdot N_c m)\) | Prediction matrices, Hessian, gradient precomputed |
| Online | \(O((N_c m)^3)\) | \(O((N_c m)^2)\) | Gaussian elimination to solve \(H\mathbf{U}=-Fx\) |
| Per step | \(O(N_c m \cdot n)\) | \(O(N_c m)\) | Matrix-vector product \(Fx\) + constraint clamping |
For typical embedded use (\(n=2, m=1, N_c=10\)): the online solve is a \(10 \times 10\) linear system, well within real-time budgets.
System: Double integrator with \(dt = 0.1\) s
\[A = \begin{bmatrix} 1 & 0.1 \\ 0 & 1 \end{bmatrix}, \quad B = \begin{bmatrix} 0.005 \\ 0.1 \end{bmatrix}\]
\[Q = \begin{bmatrix} 10 & 0 \\ 0 & 1 \end{bmatrix}, \quad R = [0.1], \quad N_p = N_c = 3\]
Step 1 — Build \(\Psi\):
\[\Psi = \begin{bmatrix} A \\ A^2 \\ A^3 \end{bmatrix} = \begin{bmatrix} 1 & 0.1 \\ 0 & 1 \\ 1 & 0.2 \\ 0 & 1 \\ 1 & 0.3 \\ 0 & 1 \end{bmatrix}\]
Step 2 — Build \(\Theta\):
\[\Theta = \begin{bmatrix} 0.005 & 0 & 0 \\ 0.1 & 0 & 0 \\ 0.015 & 0.005 & 0 \\ 0.2 & 0.1 & 0 \\ 0.03 & 0.015 & 0.005 \\ 0.3 & 0.2 & 0.1 \end{bmatrix}\]
Step 3 — Compute \(H\) and \(F\):
\(H = \Theta^T \bar{Q} \Theta + \bar{R}\) produces a \(3 \times 3\) positive definite matrix.
\(F = \Theta^T \bar{Q} \Psi\) produces a \(3 \times 2\) matrix.
Step 4 — Online solve for \(x_0 = [1, 0]^T\):
Solve \(H\, \mathbf{U}^* = -F \cdot [1, 0]^T\) via Gaussian elimination.
Apply first element \(u_0^*\) to the plant.
| Variant | Key Difference | Use Case |
|---|---|---|
| Nonlinear MPC (NMPC) | Nonlinear prediction model, requires iterative optimization | Nonlinear plants, high-accuracy |
| Explicit MPC | Precomputes control law as piecewise affine function | Very fast online evaluation |
| Move-Blocking MPC | Constrains control moves to change only at specific steps | Reduces QP size |
| Adaptive MPC | Updates plant model online | Time-varying or uncertain systems |
| Distributed MPC | Decomposes problem across subsystems | Large-scale multi-agent systems |
| Robust MPC | Accounts for bounded disturbances in constraints | Safety-critical applications |
graph LR
DARE["Discrete Algebraic<br/>Riccati Equation"] -->|terminal cost P| MPC["Model Predictive<br/>Controller"]
GE["Gaussian<br/>Elimination"] -->|solves H·U = -F·x| MPC
LQR["LQR Controller"] -.->|special case<br/>N→∞, no constraints| MPC
MPC -->|first control move| Plant["Plant Model"]
Plant -->|state measurement| MPC
A Linear Time-Invariant (LTI) system model is the fundamental mathematical object shared across state-space control, estimation, and signal processing. It captures the dynamics of a physical plant — a motor, a pendulum, a vehicle — in a compact matrix representation that enables the systematic application of optimal control and filtering theory.
Representing an LTI model as a first-class value type makes it possible to pass a single plant description into algorithms such as the Kalman Filter, LQR, LQG, and MPC, eliminating the error-prone practice of supplying the same system matrices (A, B, C, D) separately to each component.
A discrete-time LTI system with state \(x_k \in \mathbb{R}^n\), input \(u_k \in \mathbb{R}^m\), and output \(y_k \in \mathbb{R}^p\) is described by two equations:
State equation: \[x_{k+1} = A x_k + B u_k\]
Output equation: \[y_k = C x_k + D u_k\]
where: - \(A \in \mathbb{R}^{n \times n}\) is the state transition matrix - \(B \in \mathbb{R}^{n \times m}\) is the input matrix (sometimes called the control matrix) - \(C \in \mathbb{R}^{p \times n}\) is the output matrix (sometimes called the measurement matrix) - \(D \in \mathbb{R}^{p \times m}\) is the feedthrough matrix (often zero for strictly proper systems)
Full-state output (\(C = I\), \(D = 0\)): When all states are directly observable (e.g., a simulation or a state-feedback controller with perfect sensing), the output equation collapses to \(y_k = x_k\). This is the typical case for LQR.
Autonomous system (\(B = 0\), \(u_k = 0\)): A system driven only by initial conditions and process noise. Used in Kalman smoothing and spectral analysis.
Continuous-time plants described by \(\dot{x} = A_c x + B_c u\) must be discretised before use in digital controllers:
\[A = e^{A_c T_s}, \quad B = A_c^{-1}(A - I) B_c\]
where \(T_s\) is the sampling period. For small \(T_s\) and stable \(A_c\), the forward-Euler approximation \(A \approx I + A_c T_s\), \(B \approx B_c T_s\) may be used.
An LTI model is: - Stable if all eigenvalues of \(A\) lie strictly inside the unit disc (\(|\lambda_i| < 1\) for discrete-time systems). - Controllable if the controllability matrix \(\mathcal{C} = [B \; AB \; A^2B \; \cdots \; A^{n-1}B]\) has full row rank. - Observable if the observability matrix \(\mathcal{O} = [C^T \; (CA)^T \; \cdots \; (CA^{n-1})^T]^T\) has full column rank.
Controllability and observability are prerequisites for LQR and Kalman Filter stability, respectively.
| Operation | Time | Space | Notes |
|---|---|---|---|
| Step (state) | \(O(n^2 + nm)\) | \(O(n)\) | One matrix-vector multiply per term |
| Output | \(O(pn + pm)\) | \(O(p)\) | One matrix-vector multiply per term |
| Construction | \(O(1)\) | \(O(n^2 + nm + pn + pm)\) | Value-type struct, no allocation |
Consider a double integrator (position and velocity state, force input, position output) with sample period \(T_s = 0.1\,\text{s}\):
Continuous-time plant: \(\dot{x} = \begin{bmatrix} 0 & 1 \\ 0 & 0 \end{bmatrix} x + \begin{bmatrix} 0 \\ 1 \end{bmatrix} u\), \(y = \begin{bmatrix} 1 & 0 \end{bmatrix} x\)
Step 1 — Discretise using forward-Euler (\(A \approx I + A_c T_s\), \(B \approx B_c T_s\)):
\[A = \begin{bmatrix} 1 & 0.1 \\ 0 & 1 \end{bmatrix}, \quad B = \begin{bmatrix} 0 \\ 0.1 \end{bmatrix}, \quad C = \begin{bmatrix} 1 & 0 \end{bmatrix}, \quad D = \begin{bmatrix} 0 \end{bmatrix}\]
Step 2 — Simulate one step from \(x_0 = [1, 0]^T\) with \(u_0 = -2\):
\[x_1 = A x_0 + B u_0 = \begin{bmatrix} 1 & 0.1 \\ 0 & 1 \end{bmatrix} \begin{bmatrix} 1 \\ 0 \end{bmatrix} + \begin{bmatrix} 0 \\ 0.1 \end{bmatrix}(-2) = \begin{bmatrix} 1 \\ -0.2 \end{bmatrix}\]
Step 3 — Compute output at \(k=0\):
\[y_0 = C x_0 = \begin{bmatrix} 1 & 0 \end{bmatrix} \begin{bmatrix} 1 \\ 0 \end{bmatrix} = 1\]
The position measurement is \(1.0\) (exact, since \(D = 0\) and noise is excluded here).
WithFullStateOutput factory,
which sets \(C\) to the identity.A single feedback loop must balance two competing demands: fast reference tracking and robust rejection of disturbances. Tightening the feedback gains for faster tracking reduces stability margins; relaxing them for robustness slows the response. The two-degree-of-freedom (2-DOF) structure resolves this conflict by separating the two tasks into independent paths. A feedforward path shapes how the output follows the reference, while the feedback path corrects errors and rejects disturbances. Because the feedforward term is open-loop, it can be tuned for tracking without affecting the closed-loop stability margins at all.
Let \(r\) be the reference, \(y\) the plant output, and \(u\) the actuator command. The 2-DOF law is:
\[u = \text{sat}\!\left(u_{ff}(r) + u_{fb}(r - y),\; u_{min},\; u_{max}\right)\]
where: - \(u_{ff}(r)\) is the feedforward map evaluated on the reference alone (open-loop), - \(u_{fb}(e) = u_{fb}(r - y)\) is the feedback law driven by the tracking error, - \(\text{sat}(\cdot)\) clips the combined command to the actuator range \([u_{min}, u_{max}]\).
The ideal feedforward is an inverse of the plant model. For a linear plant \(P(z)\), the ideal map is \(u_{ff}(r) = P^{-1}(z)\, r\), yielding perfect steady-state tracking. A simpler approximation is a scalar gain \(K_{ff}\) tuned to match the DC plant gain: \(u_{ff}(r) = K_{ff} \cdot r\). Non-minimum-phase plants require a causal approximation since the exact inverse is non-causal.
The closed-loop characteristic equation depends only on the feedback law and the plant; \(u_{ff}\) does not appear in it. Stability margins, bandwidth, and disturbance rejection are therefore set entirely by \(u_{fb}\). This decoupling is the defining property of the 2-DOF structure.
The output clamp \(\text{sat}(u, u_{min}, u_{max})\) acts on the combined command to protect the actuator. When the sum saturates, both the feedforward and feedback contributions are implicitly reduced.
| Case | Time | Space | Notes |
|---|---|---|---|
| Best | \(O(1)\) | \(O(1)\) | Wrapper adds one addition and one clamp |
| Average | \(O(1)\) | \(O(1)\) | Cost dominated by the injected components |
| Worst | \(O(1)\) | \(O(1)\) | State lives entirely inside injected objects |
Given \(r = 0.5\), \(y = 0.1\), feedforward gain \(K_{ff} = 0.6\), and a proportional feedback with gain \(K_p = 0.5\), clamped to \([-1, 1]\):
If the system were already tracking perfectly (\(y = r = 0.5\)), then \(e = 0\), \(u_{fb} = 0\), and the feedforward alone supplies the command: \(u = K_{ff} \cdot 0.5 = 0.3\).
State-feedback controllers require the complete state vector, but physical systems typically expose only a few measured outputs. The Luenberger observer reconstructs unmeasured states from input and output measurements using a copy of the plant model continuously corrected by the difference between predicted and actual outputs.
Unlike the Kalman filter, the Luenberger observer is deterministic — it requires no noise statistics and no covariance propagation. For embedded systems with well-characterized models and deterministic environments, it offers the same state reconstruction at a fraction of the computational cost.
\[x[k+1] = A x[k] + B u[k]\] \[y[k] = C x[k] + D u[k]\]
The observer maintains a state estimate \(\hat{x}[k]\) updated by:
\[\hat{y}[k] = C \hat{x}[k] + D u[k]\] \[\hat{x}[k+1] = A \hat{x}[k] + B u[k] + L (y[k] - \hat{y}[k])\]
The term \(y[k] - \hat{y}[k]\) is the innovation (output prediction error). The gain matrix \(L \in \mathbb{R}^{n \times p}\) scales how aggressively the estimate is corrected.
Defining the estimation error \(e[k] = x[k] - \hat{x}[k]\):
\[e[k+1] = (A - LC)\, e[k]\]
The error decays to zero if and only if all eigenvalues of \((A - LC)\) lie strictly inside the unit circle. Choosing \(L\) to place those eigenvalues at desired locations is the pole placement problem for observers.
For single-output systems (\(p = 1\)), the observer gain that places eigenvalues of \((A - LC)\) at \(\{\mu_1, \dots, \mu_n\}\) is:
\[L = \varphi_d(A)\, \mathcal{O}^{-1}\, e_n\]
where: - \(\varphi_d(z) = \prod_{i=1}^n (z - \mu_i)\) is the desired characteristic polynomial, evaluated at \(A\) - \(\mathcal{O} = \begin{bmatrix} C \\ CA \\ \vdots \\ CA^{n-1} \end{bmatrix}\) is the observability matrix - \(e_n = [0, \dots, 0, 1]^T\) is the last standard basis vector
This is the dual of Ackermann’s controller placement formula.
Ackermann’s formula requires \(\mathcal{O}\) to be invertible, which holds if and only if the pair \((A, C)\) is observable: every state affects the output through some combination of shifts. An unobservable pair makes the formula degenerate — the gain cannot force arbitrary error convergence.
| Phase | Time | Space | Notes |
|---|---|---|---|
| Design (Ackermann) | \(O(n^3)\) | \(O(n^2)\) | Observability matrix build + linear solve |
| Update (per step) | \(O(n^2 + np)\) | \(O(n^2)\) | Matrix-vector products; dominant cost is \(A\hat{x}\) |
The design phase is offline. The real-time cost per sample is dominated by the \(n \times n\) state-transition multiply.
System: Double integrator, \(n=2\), \(p=1\), desired observer poles \(\{\mu_1, \mu_2\} = \{0.2,\, 0.3\}\)
\[A = \begin{bmatrix}1 & 1\\0 & 1\end{bmatrix}, \quad B = \begin{bmatrix}0\\1\end{bmatrix}, \quad C = \begin{bmatrix}1 & 0\end{bmatrix}\]
Step 1 — Build observability matrix:
\[\mathcal{O} = \begin{bmatrix}C\\CA\end{bmatrix} = \begin{bmatrix}1 & 0\\1 & 1\end{bmatrix}\]
Step 2 — Evaluate desired polynomial at \(A\):
\[\varphi_d(z) = (z - 0.2)(z - 0.3) = z^2 - 0.5z + 0.06\]
\[\varphi_d(A) = A^2 - 0.5A + 0.06I = \begin{bmatrix}0.56 & 1.5\\0 & 0.56\end{bmatrix}\]
Step 3 — Solve for gain:
\[\mathcal{O}^{-1} = \begin{bmatrix}1 & 0\\-1 & 1\end{bmatrix}, \quad \mathcal{O}^{-1} e_2 = \begin{bmatrix}0\\1\end{bmatrix}\]
\[L = \varphi_d(A) \cdot \begin{bmatrix}0\\1\end{bmatrix} = \begin{bmatrix}1.5\\0.56\end{bmatrix}\]
Verification: eigenvalues of \(A - LC = \begin{bmatrix}-0.5 & 1\\-0.56 & 1\end{bmatrix}\) are \(\{0.2, 0.3\}\). ✓
| Variant | Key Difference |
|---|---|
| Kalman Filter | Stochastic design — minimizes covariance rather than placing poles; handles noise statistics |
| Extended Luenberger Observer | Linearizes a nonlinear plant around the estimate for quasi-linear operation |
| Unknown-Input Observer | Estimates states in the presence of unmeasured disturbances |
| Reduced-Order Observer | Estimates only the unmeasured states, using measured outputs directly |
| Continuous-time observer | Uses \(\dot{\hat{x}} = A\hat{x} + Bu + L(y - C\hat{x})\); same structure, continuous pole placement |
graph LR
LO["Luenberger Observer"]
LTI["LinearTimeInvariant"]
GE["Gaussian Elimination"]
LQR["LQR Controller"]
KF["Kalman Filter"]
LTI -->|"plant model"| LO
GE -->|"solve O^{-1}"| LO
LO -->|"state estimate"| LQR
KF -.->|"stochastic counterpart"| LO
| Algorithm | Relationship |
|---|---|
| Linear Time-Invariant Model | Supplies the \((A, B, C, D)\) matrices used in both design and update steps |
| Gaussian Elimination | Solves \(\mathcal{O} x = e_n\) inside Ackermann’s formula |
| LQR Controller | Primary consumer of the observer’s state estimate |
| Kalman Filter | Stochastic counterpart; adds noise covariance propagation |
Many physical plants behave as linear systems only near a specific operating point. A motor’s electrical dynamics change with rotational speed, an aircraft’s aerodynamic response shifts with altitude and Mach number, and a chemical reactor’s behaviour depends on temperature. Designing a single linear controller that performs well across the entire operating envelope is often impossible or overly conservative.
The gain-scheduled controller addresses this by pre-computing a family of linear controllers — each optimal near one operating point — and blending between them at runtime based on a measured scheduling variable. This extends the reach of proven linear design methods to mildly nonlinear plants at negligible runtime cost, making it the dominant approach to flight control, powertrain management, and process control on resource-constrained hardware.
Let \(\sigma \in \mathbb{R}\) be the scheduling variable (e.g., speed, load, altitude) and let \(\mathbf{g}(\sigma) \in \mathbb{R}^p\) denote the vector of \(p\) controller gains as a function of \(\sigma\).
A finite set of \(N\) breakpoints \(\sigma_1 < \sigma_2 < \cdots < \sigma_N\) partitions the scheduling axis. At each breakpoint \(\sigma_i\) a gain vector \(\mathbf{g}_i \in \mathbb{R}^p\) is stored, pre-designed offline (e.g., by LQR at each linearised operating point).
For a scheduling value \(\sigma\) in the interval \([\sigma_i, \sigma_{i+1}]\), the active gain vector is the linear interpolant:
\[ \mathbf{g}(\sigma) = \mathbf{g}_i + w \, (\mathbf{g}_{i+1} - \mathbf{g}_i), \qquad w = \frac{\sigma - \sigma_i}{\sigma_{i+1} - \sigma_i} \in [0,1]. \]
Each gain component is interpolated independently. The rearranged form \(a + w(b - a)\) is numerically preferable to \((1-w)a + wb\) because it avoids catastrophic cancellation near \(w \approx 0\) and performs one fewer multiply.
Outside the table range the gains are held constant at the boundary values:
\[ \mathbf{g}(\sigma) = \begin{cases} \mathbf{g}_1 & \sigma \leq \sigma_1 \\ \mathbf{g}_N & \sigma \geq \sigma_N \end{cases} \]
Extrapolation is avoided because gain values outside the design envelope are undefined and potentially destabilising.
Gain scheduling does not guarantee closed-loop stability in general. The standard sufficient conditions are: (1) the scheduling variable changes slowly relative to the closed-loop bandwidth (quasi-static assumption), and (2) each frozen-\(\sigma\) system is stable. When the scheduling variable changes rapidly, a full parameter-varying analysis (LPV, \(\mathcal{H}_\infty\)) is required.
| Case | Time | Space | Notes |
|---|---|---|---|
| Schedule (linear search) | \(O(N + p)\) | \(O(1)\) | \(N\) interval scan, \(p\) blends |
| Schedule (binary search) | \(O(\log N + p)\) | \(O(1)\) | Preferred for large tables |
| Construction | \(O(N)\) | \(O(N \cdot p)\) | Monotonicity assertion |
All storage is stack-allocated. The table occupies \(N \cdot p\) words; the active gain vector occupies \(p\) words. No dynamic allocation occurs at any point.
Table: \(\{(0, 1), (0.5, 2), (1, 4)\}\) — three breakpoints, one gain each.
Query \(\sigma = 0.75\):
Query \(\sigma = -1.0\) (below table): saturate to \(g = g_1 = 1.0\).
| Variant | Key Difference |
|---|---|
| Bilinear / 2-D scheduling | Gains indexed by two variables (e.g., speed and load); uses bilinear interpolation on a grid |
| LPV (Linear Parameter-Varying) | Gains are polynomial functions of \(\sigma\); stability guaranteed by parameter-dependent Lyapunov functions |
| Velocity-form scheduling | Gains on incremental (velocity-form) controllers avoid output steps at scheduling transitions |
| Bumpless transfer | State initialisation at switchover prevents transients when \(\sigma\) jumps discontinuously |
| Algorithm | Relationship |
|---|---|
| LQR | Common source of the per-breakpoint gain sets; each \(\mathbf{g}_i\) is an LQR solution at the \(i\)-th linearised operating point |
| Feedforward/2-DOF | Complementary structure: the feedforward path can also be gain-scheduled to account for reference-dependent plant changes |
| MPC | Alternative that solves an optimisation online; eliminates the need for offline scheduling but requires more computation |
Classical feedback loops require a mechanism to reshape the open-loop frequency response without the full overhead of a state-space design. A one-pole/one-zero compensator achieves this with three tuning parameters and two state words, making it practical for any microcontroller control loop. When phase margin is insufficient, a lead configuration injects extra phase near the gain crossover frequency, raising stability margin and permitting a higher bandwidth. When steady-state error is the concern, a lag configuration boosts low-frequency gain to drive the error toward zero while leaving the crossover region essentially unchanged.
The compensator is defined in the Laplace domain as:
\[C(s) = K \cdot \frac{s + z}{s + p}\]
where \(K\) is the overall gain, \(z\) is the zero frequency (rad/s), and \(p\) is the pole frequency (rad/s).
The DC gain is \(C(0) = K \cdot z / p\).
The bilinear transform substitutes \(s \leftarrow \frac{2}{T_s} \cdot \frac{1 - z^{-1}}{1 + z^{-1}}\), mapping the entire left half of the \(s\)-plane to the interior of the unit circle in the \(z\)-plane and preserving stability.
Define \(c = 2/T_s\). The numerator and denominator polynomials in \(z\) are:
\[n_0 = K(c + z), \quad n_1 = K(z - c)\] \[d_0 = c + p, \quad d_1 = p - c\]
Normalizing by \(d_0\):
\[b_0 = \frac{n_0}{d_0}, \quad b_1 = \frac{n_1}{d_0}, \quad a_1 = \frac{d_1}{d_0}\]
\[y[n] = b_0 \, x[n] + b_1 \, x[n-1] - a_1 \, y[n-1]\]
The sign convention places the feedback term with a minus sign on \(a_1\), so positive \(a_1\) in the formula corresponds to a pole at \(+a_1\) inside the unit disk.
| Case | Time | Space | Notes |
|---|---|---|---|
| Best | \(O(1)\) | \(O(1)\) | Three multiply-adds, two state updates |
| Average | \(O(1)\) | \(O(1)\) | Fixed instruction count per sample |
| Worst | \(O(1)\) | \(O(1)\) | No branching; deterministic real-time behavior |
Design (Tustin coefficient computation) is \(O(1)\) and occurs once in the constructor.
Parameters: \(K=1\), \(z=1\,\text{rad/s}\), \(p=10\,\text{rad/s}\), \(T_s = 0.01\,\text{s}\) (lead network).
Unit-step response (first two samples):
| \(n\) | \(x[n]\) | \(b_0 x[n]\) | \(b_1 x[n-1]\) | \(-a_1 y[n-1]\) | \(y[n]\) |
|---|---|---|---|---|---|
| 0 | 1 | 0.9571 | 0 | 0 | 0.9571 |
| 1 | 1 | 0.9571 | −0.9476 | 0.8664 | 0.8759 |
The first output (≈ 0.957) already exceeds the DC steady-state gain of 0.1, illustrating the phase-lead kick.
Verification of DC gain: \(b_0 + b_1 = 2/210\); \(1 + a_1 = 20/210\); ratio \(= 2/20 = 0.1 = K \cdot z/p\).
Real actuators — valves, motors, heaters, servos — impose two distinct constraints on any command signal: a travel limit (the actuator cannot physically move past a hard stop) and a speed limit (the mechanism cannot slew arbitrarily fast without damaging itself or the system). Ignoring either constraint causes physically impossible commands, mechanical stress, and, in closed-loop systems, integrator wind-up that degrades recovery after saturation.
These three primitives model exactly those constraints. Saturation enforces the travel limit by clipping the output to a fixed interval. The rate limiter enforces the speed limit by allowing the output to move at most a fixed amount per sample period. Composing them — applying the rate limiter first, then the saturator — guarantees every emitted sample simultaneously respects both bounds, making them the mandatory prerequisite for anti-windup in any integrating controller.
Given bounds \([\ell, h]\) with \(\ell < h\), saturation is defined as:
\[y = \text{sat}(u) = \min\!\bigl(\max(u,\, \ell),\, h\bigr)\]
This is a memoryless, instantaneous projection onto the interval \([\ell, h]\).
Let \(\Delta_{\max} = r \cdot T_s\) be the maximum permitted change per sample, where \(r\) is the slew rate (units per second) and \(T_s\) is the sample period (seconds). Define the state \(y[n-1]\) as the last emitted output. The output at tick \(n\) is:
\[\delta[n] = \text{sat}_{\Delta_{\max}}(u[n] - y[n-1]) = \min\!\bigl(\max(u[n] - y[n-1],\,-\Delta_{\max}),\,+\Delta_{\max}\bigr)\]
\[y[n] = y[n-1] + \delta[n]\]
On the first sample (priming), \(y[0] = u[0]\) and no limiting is applied, so the output tracks the first command exactly.
\[y_{\text{composed}}[n] = \text{sat}\!\bigl(y_{\text{slew}}[n]\bigr)\]
Applying the rate limiter before the saturator is essential. If the order were reversed (clamp then slew), a step command to a saturated bound would allow the rate limiter to move the output outside the saturation interval for one tick before the clamp could correct it.
| Case | Time | Space | Notes |
|---|---|---|---|
| Best | \(O(1)\) | \(O(1)\) | Two compare-and-select operations |
| Average | \(O(1)\) | \(O(1)\) | One multiply + two compares for rate limit |
| Worst | \(O(1)\) | \(O(1)\) | Fixed instruction count; no branching on size |
Each block maintains at most two scalar parameters and one scalar
state word. The hot path is branch-light:
min/max compile to conditional-move
instructions on modern architectures.
Parameters: \(\ell = -0.5\), \(h = 0.5\), \(r = 0.1\,\text{s}^{-1}\), \(T_s = 1\,\text{s}\) (\(\Delta_{\max} = 0.1\)). Step request: \(u = 1.0\) for all ticks, starting from \(y[-1] = 0\).
| Tick \(n\) | \(u[n]\) | \(\delta[n]\) | \(y_{\text{slew}}[n]\) | \(y_{\text{composed}}[n]\) |
|---|---|---|---|---|
| 0 | 1.0 | 0.1 | 0.1 | 0.1 |
| 1 | 1.0 | 0.1 | 0.2 | 0.2 |
| 2 | 1.0 | 0.1 | 0.3 | 0.3 |
| 3 | 1.0 | 0.1 | 0.4 | 0.4 |
| 4 | 1.0 | 0.1 | 0.5 | 0.5 |
| 5 | 1.0 | 0.1 | 0.6 | 0.5 (clamped) |
The ramp is linear until the saturator clips it at \(h = 0.5\). The rate limiter continues to advance its internal state, but the composed output is held at the bound.
Reset with the desired initial state before the first
control tick.The Articulated Body Algorithm (ABA) is the most efficient method for computing forward dynamics of serial kinematic chains. Given joint positions \(q\), velocities \(\dot{q}\), and applied torques \(\tau\), it computes joint accelerations \(\ddot{q}\) in \(O(n)\) time — linear in the number of links.
This is the forward-dynamics counterpart of the Recursive Newton-Euler Algorithm (which solves inverse dynamics). Together, they form the two fundamental \(O(n)\) algorithms in rigid-body dynamics.
Alternatives like Euler-Lagrange require explicitly forming and inverting the \(n \times n\) mass matrix (\(O(n^3)\)), making ABA significantly faster for chains with more than 3-4 links.
Given a serial chain of \(n\) rigid bodies connected by revolute joints, find:
\[\ddot{q} = M(q)^{-1} [\tau - C(q, \dot{q})\dot{q} - g(q)]\]
ABA computes this without ever forming or inverting \(M\).
ABA has three passes over the kinematic chain:
Forward pass (base → tip): Compute link kinematics — angular velocities, linear velocities, and velocity-product accelerations from joint positions and velocities.
Backward pass (tip → base): Compute articulated-body inertias \(I^A_i\) and bias wrenches \(p^A_i\) for each link. At each step, the joint projection removes one degree of freedom from the spatial inertia, and the remainder is propagated to the parent.
Forward pass (base → tip): Resolve joint accelerations using the articulated-body quantities. Each joint acceleration \(\ddot{q}_i\) is computed from the projected articulated inertia and the transformed parent acceleration.
Articulated-body inertia starts as the rigid-body spatial inertia of each link, then accumulates child contributions:
\[I^A_i \leftarrow I^A_i + X^*_{i+1} \hat{I}^A_{i+1} X_{i+1}\]
where \(\hat{I}^A\) is the inertia after removing the joint DOF (the “downdate”):
\[\hat{I}^A_i = I^A_i - \frac{U_i U_i^T}{D_i}\]
with \(U_i = I^A_i s_i\), \(D_i = s_i^T U_i\), and \(s_i\) the joint motion subspace (joint axis for revolute joints).
Joint acceleration:
\[\ddot{q}_i = \frac{u_i - U_i^T \hat{a}_i}{D_i}\]
where \(u_i = \tau_i - s_i^T p^A_i\) and \(\hat{a}_i\) is the spatial acceleration contribution from the parent.
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(n)\) | \(O(n)\) | Three linear passes over the chain |
Each pass visits every link exactly once, performing constant-time spatial algebra (3×3 matrix operations) per link.
Consider a 2-link planar arm with unit masses, unit lengths, Y-axis joints, zero velocities, and zero applied torques under gravity \(g = [0, 0, -9.81]^T\).
Pass 1 — Forward kinematics: both links at \(q = [0, 0]^T\) with \(\dot{q} = [0, 0]^T\), so all velocities and velocity-product terms are zero.
Pass 2 — Backward pass: starting from link 2, compute the rigid-body inertia, project out the joint DOF, transform to link 1’s frame, and accumulate. The articulated inertia at the base now represents the effective inertia of the entire chain.
Pass 3 — Forward pass: at the base, the gravitational acceleration is transformed into the base frame and used to compute \(\ddot{q}_1\). The resulting acceleration is propagated to link 2 to compute \(\ddot{q}_2\).
The result: both joints accelerate downward due to gravity, with the base joint carrying the larger moment from the full chain.
float (not
fixed-point) for dynamics computations.RNEA(q, qDot, ABA(q, qDot, tau)) ≈ tau.The Euler-Lagrange equations provide a systematic way to derive the equations of motion for mechanical systems from energy principles. Instead of tracking forces on each body (as in Newton’s method), they express dynamics in terms of generalized coordinates \(q\) and the system’s kinetic and potential energy.
For a robotic manipulator or any articulated mechanism with \(n\) degrees of freedom, the Euler-Lagrange formulation yields a compact matrix equation:
\[M(q)\ddot{q} + C(q, \dot{q})\dot{q} + g(q) = \tau\]
Where: - \(q \in \mathbb{R}^n\) — generalized coordinates (joint angles/positions) - \(\dot{q}, \ddot{q}\) — generalized velocities and accelerations - \(M(q) \in \mathbb{R}^{n \times n}\) — mass (inertia) matrix, symmetric positive definite - \(C(q, \dot{q})\dot{q} \in \mathbb{R}^n\) — Coriolis and centrifugal terms - \(g(q) \in \mathbb{R}^n\) — gravitational terms - \(\tau \in \mathbb{R}^n\) — generalized forces/torques (inputs)
This formulation supports two fundamental computations: - Forward dynamics: given torques \(\tau\), compute accelerations \(\ddot{q} = M^{-1}(\tau - C\dot{q} - g)\) - Inverse dynamics: given desired accelerations \(\ddot{q}\), compute required torques \(\tau = M\ddot{q} + C\dot{q} + g\)
The Lagrangian of a system is defined as:
\[\mathcal{L}(q, \dot{q}) = T(q, \dot{q}) - V(q)\]
where \(T\) is kinetic energy and \(V\) is potential energy.
The Euler-Lagrange equation for coordinate \(q_i\) is:
\[\frac{d}{dt}\frac{\partial \mathcal{L}}{\partial \dot{q}_i} - \frac{\partial \mathcal{L}}{\partial q_i} = \tau_i \quad \text{for } i = 1, \ldots, n\]
For a rigid-body system, kinetic energy takes the form:
\[T = \frac{1}{2}\dot{q}^T M(q) \dot{q}\]
Applying the Euler-Lagrange equation to this quadratic form yields the standard manipulator equation:
\[M(q)\ddot{q} + C(q, \dot{q})\dot{q} + g(q) = \tau\]
The mass matrix is always symmetric (\(M = M^T\)) and positive definite (\(\dot{q}^T M \dot{q} > 0\) for \(\dot{q} \neq 0\)). These properties guarantee: - The forward dynamics linear system \(M\ddot{q} = f\) always has a unique solution - Physical kinetic energy is always non-negative
The Coriolis matrix captures velocity-dependent forces using Christoffel symbols of the first kind:
\[c_{ij} = \sum_{k=1}^{n} \frac{1}{2}\left(\frac{\partial m_{ij}}{\partial q_k} + \frac{\partial m_{ik}}{\partial q_j} - \frac{\partial m_{jk}}{\partial q_i}\right)\dot{q}_k\]
A key property: \(\dot{M} - 2C\) is skew-symmetric, which is important for passivity-based control.
\[g_i(q) = \frac{\partial V}{\partial q_i}\]
where \(V(q)\) is the total potential energy of the system.
| Operation | Time | Space | Notes |
|---|---|---|---|
| Forward dynamics | \(O(n^3)\) | \(O(n^2)\) | Dominated by \(M^{-1}\) solve via Gaussian elimination |
| Inverse dynamics | \(O(n^2)\) | \(O(n^2)\) | Matrix-vector multiplication \(M \ddot{q}\) |
| Model evaluation | Model-dependent | \(O(n^2)\) | Computing \(M(q)\), \(C(q,\dot{q})\), \(g(q)\) |
For systems with \(n \leq 6\) DOF (typical robotic manipulators), the \(O(n^3)\) cost of Gaussian elimination is negligible. For larger systems, consider the Recursive Newton-Euler Algorithm (RNEA) which achieves \(O(n)\) inverse dynamics.
A point mass \(m\) on a rigid rod of length \(l\):
System parameters: \(m = 1\,\text{kg}\), \(l = 1\,\text{m}\), \(g_0 = 9.81\,\text{m/s}^2\)
Model matrices: - \(M(q) = ml^2 = 1.0\) - \(C(q, \dot{q})\dot{q} = 0\) (single DOF → no Coriolis) - \(g(q) = mgl\sin(q) = 9.81\sin(q)\)
Forward dynamics at \(q = 0.5\,\text{rad}\), \(\dot{q} = 0\), \(\tau = 0\):
\[\ddot{q} = M^{-1}(\tau - C\dot{q} - g) = \frac{0 - 0 - 9.81\sin(0.5)}{1.0} = -4.703\,\text{rad/s}^2\]
Inverse dynamics for holding position (\(\ddot{q} = 0\)):
\[\tau = M\ddot{q} + C\dot{q} + g = 0 + 0 + 9.81\sin(0.5) = 4.703\,\text{N·m}\]
For a 2-DOF arm with equal links (\(m_1 = m_2 = 1\,\text{kg}\), \(l_1 = l_2 = 1\,\text{m}\)):
At rest (\(q = [0, 0]^T\), \(\dot{q} = [0, 0]^T\)): - \(\sin(q_1) = 0\), \(\sin(q_1 + q_2) = 0\) - \(g = [0, 0]^T\) → gravity terms vanish when links hang vertically - Forward dynamics with \(\tau = 0\) produces \(\ddot{q} = [0, 0]^T\) (equilibrium)
float unless
inputs are carefully scaled.| Variant | Trade-off | Use Case |
|---|---|---|
| Euler-Lagrange (this) | \(O(n^3)\) forward, closed-form, intuitive | Small systems (\(n \leq 6\)), control design, analysis |
| Newton-Euler | Body-by-body force balance | Single rigid bodies, intuitive for simple systems |
| Recursive Newton-Euler (RNEA) | \(O(n)\) inverse dynamics | Large kinematic chains, real-time control |
| Articulated Body Algorithm (ABA) | \(O(n)\) forward dynamics | Large chains, simulation |
| Hamiltonian formulation | Phase-space, symplectic integration | Long-horizon simulation, energy preservation |
The Newton-Euler formulation describes the dynamics of a single rigid body by directly applying Newton’s second law for translational motion and Euler’s equation for rotational motion. Unlike the Euler-Lagrange approach which works in generalized (joint-space) coordinates, Newton-Euler operates in Cartesian space, computing forces and torques on individual bodies.
In the body-fixed reference frame, the equations are:
\[F = m(\dot{v} + \omega \times v)\] \[\tau = I\dot{\omega} + \omega \times (I\omega)\]
Where: - \(F \in \mathbb{R}^3\) — applied force in the body frame - \(\tau \in \mathbb{R}^3\) — applied torque in the body frame - \(m\) — body mass (scalar) - \(I \in \mathbb{R}^{3 \times 3}\) — inertia tensor in the body frame - \(v, \dot{v} \in \mathbb{R}^3\) — linear velocity and acceleration in the body frame - \(\omega, \dot{\omega} \in \mathbb{R}^3\) — angular velocity and acceleration in the body frame - \(\omega \times v\) — Coriolis term due to body-frame formulation - \(\omega \times (I\omega)\) — gyroscopic coupling term
This supports two computations: - Forward dynamics: given forces/torques, compute linear and angular accelerations - Inverse dynamics: given desired accelerations, compute required forces/torques
In an inertial frame, Newton’s second law is simply \(F = ma\). In a body-fixed (rotating) frame, the transport theorem introduces an additional term:
\[F = m(\dot{v} + \omega \times v)\]
The \(\omega \times v\) term is the Coriolis acceleration that arises because the reference frame itself is rotating.
Forward: \(\dot{v} = F/m - \omega \times v\)
Inverse: \(F = m(\dot{v} + \omega \times v)\)
Euler’s equation governs rotational dynamics:
\[\tau = I\dot{\omega} + \omega \times (I\omega)\]
The \(\omega \times (I\omega)\) term is the gyroscopic torque — it couples different rotational axes when the inertia tensor is non-spherical. For a body spinning about a non-principal axis, this term causes precession even without external torque.
Forward: \(\dot{\omega} = I^{-1}(\tau - \omega \times (I\omega))\)
This requires solving the \(3 \times 3\) linear system \(I\dot{\omega} = \tau - \omega \times (I\omega)\) via Gaussian elimination.
Inverse: \(\tau = I\dot{\omega} + \omega \times (I\omega)\)
This is a direct matrix-vector multiply plus cross product — no linear solve needed.
The inertia tensor \(I\) is: - Symmetric: \(I = I^T\) (6 independent components, not 9) - Positive definite: \(\omega^T I \omega > 0\) for \(\omega \neq 0\) - Constant in body frame: For a rigid body, \(I\) does not change with the body’s orientation when expressed in the body frame
When \(I\) is diagonal (principal axes frame), components are the principal moments of inertia \(I_{xx}, I_{yy}, I_{zz}\), and Euler’s equation simplifies to the well-known form:
\[\tau_x = I_{xx}\dot{\omega}_x + (I_{zz} - I_{yy})\omega_y\omega_z\] \[\tau_y = I_{yy}\dot{\omega}_y + (I_{xx} - I_{zz})\omega_z\omega_x\] \[\tau_z = I_{zz}\dot{\omega}_z + (I_{yy} - I_{xx})\omega_x\omega_y\]
| Operation | Time | Space | Notes |
|---|---|---|---|
| Forward dynamics | \(O(1)\) | \(O(1)\) | Fixed 3×3 system; Gaussian elimination on 3×3 matrix |
| Inverse dynamics | \(O(1)\) | \(O(1)\) | Matrix-vector multiply + cross products |
| Cross product | \(O(1)\) | \(O(1)\) | 6 multiplications and 3 subtractions |
All operations are constant-time since the spatial dimension is fixed at 3. The \(3 \times 3\) Gaussian elimination is effectively a small constant cost.
Parameters: mass \(m = 2\,\text{kg}\), radius \(r = 0.5\,\text{m}\)
Inertia tensor: \(I = \frac{2}{5}mr^2 \cdot \mathbf{I}_3 = 0.2 \cdot \mathbf{I}_3\)
Forward dynamics with \(F = [6, 0, 0]^T\,\text{N}\), \(\tau = [0, 0, 1]^T\,\text{N·m}\), \(v = \omega = 0\):
\[\dot{v} = F/m - 0 = [3, 0, 0]^T \,\text{m/s}^2\] \[\dot{\omega} = I^{-1}\tau = [0, 0, 5]^T \,\text{rad/s}^2\]
Asymmetric body with \(I = \text{diag}(1, 2, 3)\,\text{kg·m}^2\)
Spinning at \(\omega = [1, 1, 0]^T\,\text{rad/s}\) with no external torque:
\[I\omega = [1, 2, 0]^T\] \[\omega \times (I\omega) = [1,1,0] \times [1,2,0] = [0, 0, 1]^T\] \[\dot{\omega} = I^{-1}(0 - [0,0,1]) = [0, 0, -\tfrac{1}{3}]^T \,\text{rad/s}^2\]
The body accelerates about the z-axis despite no external torque — this is the gyroscopic coupling effect due to asymmetric inertia.
float.| Variant | Key Difference | Use Case |
|---|---|---|
| Newton-Euler (this) | Single rigid body, Cartesian space | Spacecraft attitude, single-body simulation |
| Recursive Newton-Euler (RNEA) | Multi-body recursive algorithm, \(O(n)\) | Robot inverse dynamics, real-time control |
| Euler-Lagrange | Generalized coordinates, joint space | Control design, analytical derivations |
| Spatial vector algebra | 6D twist/wrench representation | Compact multi-body formulations |
The Recursive Newton-Euler Algorithm (RNEA) is the most efficient method for computing inverse dynamics of serial kinematic chains (robot arms, manipulators). Given joint positions, velocities, and desired accelerations, it computes the required joint torques in \(O(n)\) time — linear in the number of links.
This contrasts with the Euler-Lagrange approach, which requires explicitly forming and multiplying \(n \times n\) matrices (\(O(n^3)\)). For a 6-DOF robot arm, RNEA is roughly 10× faster than the matrix-based approach.
The algorithm has two passes over the kinematic chain:
The joint torque at each joint is then the projection of the net torque onto the joint axis.
A serial chain of \(n\) rigid bodies (links) connected by revolute joints. Each link \(i\) is described by:
The rotation matrix \(R_i(q_i)\) transforms vectors from link \(i\) frame to parent frame, computed via Rodrigues’ formula from the joint angle \(q_i\) and joint axis \(\hat{z}_i\).
For link \(i\), given parent link \(i-1\) quantities:
Angular velocity: \[\omega_i = R_i^T \omega_{i-1} + \dot{q}_i \hat{z}_i\]
Angular acceleration: \[\dot{\omega}_i = R_i^T \dot{\omega}_{i-1} + \ddot{q}_i \hat{z}_i + \omega_i \times (\dot{q}_i \hat{z}_i)\]
Linear acceleration at joint origin of link \(i\): \[a_{J_i} = R_i^T \left( a_{J_{i-1}} + \dot{\omega}_{i-1} \times r_{p \to j}^{(i)} + \omega_{i-1} \times (\omega_{i-1} \times r_{p \to j}^{(i)}) \right)\]
Linear acceleration at center of mass: \[a_{C_i} = a_{J_i} + \dot{\omega}_i \times r_{j \to c}^{(i)} + \omega_i \times (\omega_i \times r_{j \to c}^{(i)})\]
For the base link (\(i = 0\)), the “parent” is the world frame with \(\omega_{-1} = 0\) and \(a_{J_{-1}} = -g\) (gravity expressed as a base acceleration, a standard RNEA convention).
For each link \(i\), starting from the tip:
Net force on link \(i\): \[f_i = m_i a_{C_i} + \sum_{\text{children } j} R_j f_j\]
Net torque on link \(i\) (about joint): \[\tau_i = I_i \dot{\omega}_i + \omega_i \times (I_i \omega_i) + r_{j \to c}^{(i)} \times (m_i a_{C_i}) + \sum_{\text{children } j} \left( R_j \tau_j + r_{p \to j}^{(j)} \times R_j f_j \right)\]
Joint torque: \[\tau_{q_i} = \hat{z}_i^T \tau_i\]
The rotation matrix for angle \(\theta\) about unit axis \(\hat{u} = [u_x, u_y, u_z]^T\):
\[R = \cos\theta \cdot \mathbf{I}_3 + (1 - \cos\theta) \hat{u}\hat{u}^T + \sin\theta [\hat{u}]_\times\]
where \([\hat{u}]_\times\) is the skew-symmetric matrix of \(\hat{u}\).
| Operation | Time | Space | Notes |
|---|---|---|---|
| Inverse dynamics | \(O(n)\) | \(O(n)\) | Two linear passes over \(n\) links |
| Per-link forward pass | \(O(1)\) | \(O(1)\) | Fixed 3×3 matrix-vector operations |
| Per-link backward pass | \(O(1)\) | \(O(1)\) | Cross products and additions |
| Rotation matrix | \(O(1)\) | \(O(1)\) | Rodrigues’ formula: trig functions + 3×3 matrix |
Compared to Euler-Lagrange \(O(n^3)\) inverse dynamics, RNEA is dramatically faster for large \(n\). For \(n = 6\) (typical robot arm), RNEA performs roughly 780 floating-point operations vs. ~14,000 for the matrix approach.
A single thin rod of mass \(m = 2\,\text{kg}\) and length \(l = 1\,\text{m}\), rotating about the z-axis. Joint at origin, CoM at \([l/2, 0, 0]^T\).
Inertia at CoM: \(I = ml^2/12 = 0.167\,\text{kg·m}^2\) (about y and z axes)
Setup: \(q = 0\), \(\dot{q} = 0\), \(\ddot{q} = 1\,\text{rad/s}^2\), zero gravity.
Forward pass: - \(\omega_0 = [0, 0, 0]^T + 0 \cdot [0, 0, 1]^T = [0, 0, 0]^T\) - \(\dot{\omega}_0 = [0, 0, 1]^T\) (from \(\ddot{q}\)) - \(a_{C_0} = 0 + [0, 0, 1] \times [0.5, 0, 0] + 0 = [0, 0.5, 0]^T\,\text{m/s}^2\)
Backward pass: - \(f_0 = 2 \cdot [0, 0.5, 0]^T = [0, 1, 0]^T\,\text{N}\) - \(\tau_0 = I \cdot [0, 0, 1]^T + 0 + [0.5, 0, 0] \times [0, 1, 0]^T\) - \(= [0, 0, 0.167]^T + [0, 0, 0.5]^T = [0, 0, 0.667]^T\) - \(\tau_q = [0, 0, 1]^T \cdot [0, 0, 0.667]^T = 0.667\,\text{N·m}\)
This matches \(I_{\text{end}} \cdot \ddot{q} = \frac{ml^2}{3} \cdot 1 = 0.667\).
float.| Variant | Key Difference | Use Case |
|---|---|---|
| RNEA (this) | \(O(n)\) inverse dynamics, serial chain | Real-time robot control, computed torque |
| RNEA + mass matrix | Call RNEA \(n+1\) times to build \(M(q)\) | Forward dynamics via \(\ddot{q} = M^{-1}(\tau - h)\) |
| Articulated Body Algorithm (ABA) | \(O(n)\) forward dynamics directly | Simulation without forming \(M\) |
| Extended RNEA | Includes friction, motor inertia | Realistic robot modeling |
| RNEA for trees | Handles branching chains | Humanoid robots, multi-fingered hands |
Given a set of observations \(\{(x_i, y_i)\}\), we often want to find the best-fit linear relationship between features \(x\) and target \(y\). Linear regression does this by finding the coefficients \(\beta\) that minimize the sum of squared residuals — the gap between predicted and observed values.
The approach is fundamental because: - It has a closed-form solution (the normal equation), so no iterative optimization is needed. - It provides a baseline model against which more complex methods are measured. - The solution is the maximum likelihood estimator under Gaussian noise assumptions.
This library solves the normal equation directly using Gaussian elimination, making it suitable for small-to-moderate feature counts typical in embedded estimation tasks.
\[y = \beta_0 + \beta_1 x_1 + \beta_2 x_2 + \cdots + \beta_p x_p + \varepsilon\]
In matrix form, augmenting \(X\) with a column of ones for the intercept:
\[\mathbf{y} = \mathbf{X}\boldsymbol{\beta} + \boldsymbol{\varepsilon}\]
where \(\mathbf{X} \in \mathbb{R}^{n \times (p+1)}\), \(\boldsymbol{\beta} \in \mathbb{R}^{p+1}\), \(\mathbf{y} \in \mathbb{R}^n\).
Minimizing \(\|\mathbf{y} - \mathbf{X}\boldsymbol{\beta}\|^2\) with respect to \(\boldsymbol{\beta}\) yields:
\[\boldsymbol{\beta} = (\mathbf{X}^T \mathbf{X})^{-1} \mathbf{X}^T \mathbf{y}\]
This is solved in practice by forming \(\mathbf{X}^T\mathbf{X}\) and \(\mathbf{X}^T\mathbf{y}\), then using Gaussian elimination to solve the \((p+1) \times (p+1)\) system.
The prediction \(\hat{\mathbf{y}} = \mathbf{X}\boldsymbol{\beta}\) is the orthogonal projection of \(\mathbf{y}\) onto the column space of \(\mathbf{X}\). The residual \(\mathbf{y} - \hat{\mathbf{y}}\) is perpendicular to every column of \(\mathbf{X}\).
| Phase | Time | Space | Notes |
|---|---|---|---|
| \(\mathbf{X}^T\mathbf{X}\) | \(O(n p^2)\) | \(O(p^2)\) | Dominated by matrix multiply |
| \(\mathbf{X}^T\mathbf{y}\) | \(O(np)\) | \(O(p)\) | Matrix-vector multiply |
| Solve | \(O(p^3)\) | \(O(p^2)\) | Gaussian elimination |
| Predict | \(O(p)\) | \(O(1)\) | Dot product |
For embedded use with small \(p\) (say \(\leq 10\)), the entire fit completes in microseconds.
Data: 3 samples, 1 feature.
| \(x\) | \(y\) |
|---|---|
| 1 | 2 |
| 2 | 4 |
| 3 | 5 |
Step 1 — Design matrix (with bias column):
\[\mathbf{X} = \begin{bmatrix} 1 & 1 \\ 1 & 2 \\ 1 & 3 \end{bmatrix}\]
Step 2 — Compute \(\mathbf{X}^T\mathbf{X}\) and \(\mathbf{X}^T\mathbf{y}\):
\[\mathbf{X}^T\mathbf{X} = \begin{bmatrix} 3 & 6 \\ 6 & 14 \end{bmatrix}, \quad \mathbf{X}^T\mathbf{y} = \begin{bmatrix} 11 \\ 23 \end{bmatrix}\]
Step 3 — Solve \(\begin{bmatrix} 3 & 6 \\ 6 & 14 \end{bmatrix} \boldsymbol{\beta} = \begin{bmatrix} 11 \\ 23 \end{bmatrix}\):
Forward elimination → back-substitution: \(\beta_1 = 1.5\), \(\beta_0 = 2/3 \approx 0.667\)
Result: \(\hat{y} = 0.667 + 1.5x\)
Prediction at \(x = 4\): \(\hat{y} = 0.667 + 6.0 = 6.667\)
| Variant | Key Difference |
|---|---|
| Ridge regression (L2) | Adds \(\lambda\|\boldsymbol{\beta}\|^2\) to the cost; solves \((\mathbf{X}^T\mathbf{X} + \lambda I)\boldsymbol{\beta} = \mathbf{X}^T\mathbf{y}\) |
| Lasso regression (L1) | Adds \(\lambda\|\boldsymbol{\beta}\|_1\); promotes sparsity but requires iterative optimization |
| Polynomial regression | Adds powers of \(x\) as new features; still linear in parameters |
| Weighted least squares | Weights each sample differently; solves \((\mathbf{X}^T W \mathbf{X})\boldsymbol{\beta} = \mathbf{X}^T W \mathbf{y}\) |
| Recursive least squares | Updates \(\boldsymbol{\beta}\) incrementally as new data arrives; suited for online estimation |
graph LR
LR["Linear Regression"]
GE["Gaussian Elimination"]
YW["Yule-Walker"]
NN["Neural Network"]
LR --> GE
YW -.->|"similar normal-equation structure"| LR
NN -.->|"single linear layer = regression"| LR
| Algorithm | Relationship |
|---|---|
| Gaussian Elimination | Used to solve the normal equation system |
| Yule-Walker | Structurally similar — also solves a linear system derived from correlations |
| Neural Network | A single-layer neural network with no activation and MSE loss reduces to linear regression |
Sensor calibration curves, ADC linearization, thermistor transfer functions, and drift trends all require fitting a smooth curve to a discrete set of measured points. A degree-\(d\) polynomial captures these behaviors with only \(d+1\) coefficients, making evaluation at runtime a handful of multiply-adds rather than a table lookup or expensive transcendental.
The least-squares formulation finds the polynomial that minimizes the sum of squared residuals across all measurement samples. Unlike exact interpolation, it is robust to measurement noise: extra samples average out errors rather than being forced to pass through noisy points.
Given \(n\) scalar observations \(\{(x_i, y_i)\}_{i=0}^{n-1}\), the degree-\(d\) polynomial model is
\[p(x) = c_0 + c_1 x + c_2 x^2 + \cdots + c_d x^d\]
The goal is to find the coefficient vector \(\mathbf{c} \in \mathbb{R}^{d+1}\) that minimizes
\[\min_{\mathbf{c}} \sum_{i=0}^{n-1} \bigl(y_i - p(x_i)\bigr)^2\]
Stacking the model evaluations at all sample abscissae gives the Vandermonde matrix
\[\mathbf{V} \in \mathbb{R}^{n \times (d+1)}, \quad V_{i,j} = x_i^j\]
The least-squares problem then becomes \(\min_{\mathbf{c}} \|\mathbf{V}\mathbf{c} - \mathbf{y}\|^2\).
Setting the gradient of the squared residual with respect to \(\mathbf{c}\) to zero yields
\[(\mathbf{V}^\top \mathbf{V})\,\mathbf{c} = \mathbf{V}^\top \mathbf{y}\]
The \((d+1)\times(d+1)\) matrix \(\mathbf{V}^\top\mathbf{V}\) is symmetric and, when the abscissae are distinct and \(n \geq d+1\), positive-definite. Its small size allows direct solution by Gaussian elimination or Cholesky factorization in bounded time on embedded hardware.
Once \(\mathbf{c}\) is known, evaluating \(p(x)\) at a new point uses Horner’s method
\[p(x) = c_0 + x\bigl(c_1 + x\bigl(c_2 + \cdots + x\,c_d\bigr)\cdots\bigr)\]
This requires exactly \(d\) multiplications and \(d\) additions — optimal for a degree-\(d\) polynomial.
| Phase | Time | Space | Notes |
|---|---|---|---|
| Build \(\mathbf{V}\) | \(O(n\,d)\) | \(O(n\,d)\) | Incremental powers, no pow() |
| Form \(\mathbf{V}^\top\mathbf{V}\) | \(O(n\,d^2)\) | \(O(d^2)\) | Symmetric, only upper half needed |
| Form \(\mathbf{V}^\top\mathbf{y}\) | \(O(n\,d)\) | \(O(d)\) | Matrix-vector product |
| Solve \((d+1)\times(d+1)\) system | \(O(d^3)\) | \(O(d^2)\) | Gaussian elimination |
| Predict (Horner) | \(O(d)\) | \(O(1)\) | One MAC per coefficient |
All dimensions are compile-time constants; no heap allocation is required.
Data: \(n = 4\) samples, \(d = 2\) (quadratic fit).
| \(x_i\) | \(y_i\) |
|---|---|
| 0 | 1 |
| 1 | 0.75 |
| 2 | 1 |
| 3 | 1.75 |
Step 1 — Build \(\mathbf{V}\):
\[\mathbf{V} = \begin{bmatrix} 1 & 0 & 0 \\ 1 & 1 & 1 \\ 1 & 2 & 4 \\ 1 & 3 & 9 \end{bmatrix}\]
Step 2 — Normal equations:
\[\mathbf{V}^\top\mathbf{V} = \begin{bmatrix} 4 & 6 & 14 \\ 6 & 14 & 36 \\ 14 & 36 & 98 \end{bmatrix}, \qquad \mathbf{V}^\top\mathbf{y} = \begin{bmatrix} 4.5 \\ 7.25 \\ 19.75 \end{bmatrix}\]
Step 3 — Solve: Gaussian elimination → \(\mathbf{c} \approx [1,\,-0.5,\,0.25]^\top\).
Result: \(p(x) = 1 - 0.5\,x + 0.25\,x^2\).
Prediction at \(x = 1.5\):
\[p(1.5) = 0.25\cdot1.5^2 - 0.5\cdot1.5 + 1 = 0.5625 - 0.75 + 1 = 0.8125\]
Ill-conditioning of the Vandermonde system. The condition number of \(\mathbf{V}^\top\mathbf{V}\) grows exponentially with \(d\) and with the spread of abscissae. Center and scale the abscissa \(x \leftarrow (x - \bar{x})/\sigma_x\) before fitting to reduce condition numbers by orders of magnitude. Recommended for \(d \geq 3\) or when abscissae are far from the origin.
Degree selection. Over-fitting occurs when \(d\) is too large relative to \(n\) or to the signal-to-noise ratio. Keep \(d \leq 4\) for typical embedded calibration tasks.
Exactly \(n = d+1\) points. The normal equation system has a unique solution equal to the interpolating polynomial; the residual is zero. The system is well-posed only if all abscissae are distinct.
Repeated or nearly-coincident abscissae. \(\mathbf{V}^\top\mathbf{V}\) becomes
singular or nearly so. Partial-pivoting in the Gaussian solver will flag
this via really_assert; avoid duplicate \(x\) values in practice.
Large degree with float arithmetic.
Powers \(x^d\) for \(|x| \gg 1\) can exceed the
float dynamic range. Centering/scaling eliminates this
risk.
| Variant | Key Difference |
|---|---|
| Orthogonal polynomial basis | Uses Legendre/Chebyshev basis instead of monomials; much better conditioning |
| Weighted least squares | Each sample weighted differently (e.g., by measurement precision) |
| Regularized (Ridge) fitting | Adds \(\lambda\|\mathbf{c}\|^2\) to damp large coefficients |
| Constrained fitting | Enforces derivative constraints at endpoints |
| Savitzky-Golay smoothing | Sliding-window polynomial fit for real-time derivative estimation |
graph LR
PF["Polynomial Fitting"]
GE["Gaussian Elimination"]
LR["Linear Regression"]
SG["Savitzky-Golay (planned)"]
RLS["Recursive Least Squares"]
PF --> GE
PF -.->|"polynomial features = special case"| LR
SG -.->|"local polynomial fit per window"| PF
RLS -.->|"online counterpart"| PF
| Algorithm | Relationship |
|---|---|
| Gaussian Elimination | Solves the normal equations |
| Linear Regression | Polynomial fitting is linear regression with polynomial features |
| Recursive Least Squares | Online / streaming counterpart for time-varying models |
Many signals — vibration data, speech waveforms, financial time series — exhibit short-term correlations. An autoregressive (AR) model captures these correlations by expressing the current sample as a linear combination of the previous \(p\) samples plus white noise. The Yule-Walker method estimates the AR coefficients directly from the signal’s autocovariance structure.
The appeal is threefold: 1. The resulting system of equations has Toeplitz structure, enabling efficient \(O(p^2)\) solvers. 2. The method is guaranteed to produce a stable model (all poles inside the unit circle) when the autocovariance matrix is positive definite. 3. It provides a parametric spectral estimate — the PSD is a smooth rational function rather than a noisy periodogram.
An AR(\(p\)) process:
\[x[t] = \varphi_1 x[t-1] + \varphi_2 x[t-2] + \cdots + \varphi_p x[t-p] + \varepsilon[t]\]
where \(\varepsilon[t]\) is white noise with variance \(\sigma^2\).
Multiplying both sides by \(x[t-k]\) and taking expectations:
\[R[k] = \varphi_1 R[k-1] + \varphi_2 R[k-2] + \cdots + \varphi_p R[k-p], \quad k = 1, \ldots, p\]
where \(R[k] = E[x[t] \cdot x[t-k]]\) is the autocovariance at lag \(k\). In matrix form:
\[\underbrace{\begin{bmatrix} R[0] & R[1] & \cdots & R[p-1] \\ R[1] & R[0] & \cdots & R[p-2] \\ \vdots & & \ddots & \vdots \\ R[p-1] & R[p-2] & \cdots & R[0] \end{bmatrix}}_{\mathbf{R} \text{ (Toeplitz)}} \begin{bmatrix} \varphi_1 \\ \varphi_2 \\ \vdots \\ \varphi_p \end{bmatrix} = \begin{bmatrix} R[1] \\ R[2] \\ \vdots \\ R[p] \end{bmatrix}\]
The biased estimator is used:
\[\hat{R}[k] = \frac{1}{N} \sum_{t=k}^{N-1} x[t] \cdot x[t-k]\]
after centering the series by subtracting its mean. The biased estimator (dividing by \(N\) instead of \(N-k\)) guarantees the Toeplitz matrix is positive semi-definite.
The PSD of the estimated AR model is:
\[S(f) = \frac{\sigma^2}{\left|1 - \sum_{i=1}^{p} \varphi_i \, e^{-j2\pi fi}\right|^2}\]
This gives a smooth, parametric spectral estimate.
| Phase | Time | Space | Notes |
|---|---|---|---|
| Autocovariance | \(O(Np)\) | \(O(p)\) | One pass per lag, \(p+1\) lags |
| Build Toeplitz matrix | \(O(p^2)\) | \(O(p^2)\) | Fill from autocovariance vector |
| Solve (Levinson-Durbin) | \(O(p^2)\) | \(O(p)\) | Exploits Toeplitz structure |
| Solve (Gaussian elim.) | \(O(p^3)\) | \(O(p^2)\) | General-purpose fallback |
| Predict | \(O(p)\) | \(O(1)\) | Dot product with past samples |
Input: \(x = [2, 4, 6, 5, 3, 1]\), AR order \(p = 2\).
Step 1 — Compute mean and center:
\(\bar{x} = 3.5\), centered: \(x' = [-1.5, 0.5, 2.5, 1.5, -0.5, -2.5]\)
Step 2 — Autocovariances (biased, divide by \(N = 6\)):
Step 3 — Build Toeplitz system:
\[\begin{bmatrix} 2.917 & 0.792 \\ 0.792 & 2.917 \end{bmatrix} \begin{bmatrix} \varphi_1 \\ \varphi_2 \end{bmatrix} = \begin{bmatrix} 0.792 \\ -1.333 \end{bmatrix}\]
Step 4 — Solve (e.g., via Levinson-Durbin or Gaussian elimination):
\(\varphi_1 \approx 0.478\), \(\varphi_2 \approx -0.587\)
Prediction: \(\hat{x}[6] = \bar{x} + \varphi_1 (x[5] - \bar{x}) + \varphi_2 (x[4] - \bar{x})\)
| Variant | Key Difference |
|---|---|
| Burg’s method | Estimates AR coefficients from forward + backward prediction errors; often more accurate for short records |
| Covariance method | Uses the unbiased autocovariance; does not guarantee stability |
| ARMA models | Extends AR to include moving-average terms; more flexible but requires iterative estimation |
| Vector autoregression (VAR) | Multivariate extension for jointly modeling multiple time series |
| Recursive estimation | Updates AR coefficients online as new data arrives |
graph LR
YW["Yule-Walker"]
LD["Levinson-Durbin"]
GE["Gaussian Elimination"]
PSD["Power Spectral Density"]
LR["Linear Regression"]
YW --> LD
YW --> GE
YW -.->|"parametric alternative"| PSD
LR -.->|"similar structure"| YW
| Algorithm | Relationship |
|---|---|
| Levinson-Durbin | Efficient \(O(p^2)\) solver exploiting the Toeplitz structure of the Yule-Walker matrix |
| Gaussian Elimination | General-purpose \(O(p^3)\) solver used as a fallback |
| Power Spectral Density | Non-parametric alternative; the AR model provides a parametric PSD estimate |
| Linear Regression | Structurally similar — both solve a linear system derived from data correlations |
A Kalman filter requires four parameter matrices — the state-transition matrix \(F\), measurement matrix \(H\), process-noise covariance \(Q\), and measurement-noise covariance \(R\) — to be known a priori. In many real systems these parameters are unknown or only approximately known.
The Expectation-Maximization (EM) algorithm for linear Gaussian state-space models (Shumway & Stoffer, 1982; 2000) treats the latent state sequence \(x_{1:T}\) as missing data and iteratively finds the maximum-likelihood parameter estimate from a batch of observations \(z_{1:T}\). Each iteration of EM is guaranteed to be non-decreasing in log-likelihood, providing a principled convergence criterion.
This implementation follows the Shumway-Stoffer formulation and
operates exclusively in float because typical dynamics
(forces, velocities, torques) exceed the \([-1, 1]\) range of Q15/Q31 fixed-point
formats.
\[ x_{t+1} = F x_t + w_t, \quad w_t \sim \mathcal{N}(0, Q) \]
\[ z_t = H x_t + v_t, \quad v_t \sim \mathcal{N}(0, R) \]
with \(x_0 \sim \mathcal{N}(\mu_0, P_0)\) and \(T\) observations \(z_0, \ldots, z_{T-1}\).
EM alternates between:
Run KalmanSmoother::Smooth() to obtain, for each \(t\):
| Quantity | Notation | Description |
|---|---|---|
| \(\hat{x}_{t\|T}\) | smoothedMeans[t] |
Smoothed state mean |
| \(P_{t\|T}\) | smoothedCovariances[t] |
Smoothed state covariance |
| \(P_{t,t-1\|T}\) | lagCrossCovariances[t] |
Lag-1 cross-covariance (index 0 undefined) |
From the smoother output, accumulate six matrices:
\[ A = \sum_{t=1}^{T-1} P_{t,t-1|T} \qquad B = \sum_{t=0}^{T-2} \hat{x}_{t|T}\hat{x}_{t|T}^\top + P_{t|T} \]
\[ C = \sum_{t=1}^{T-1} \hat{x}_{t|T}\hat{x}_{t|T}^\top + P_{t|T} \qquad D = \sum_{t=0}^{T-1} \hat{x}_{t|T}\hat{x}_{t|T}^\top + P_{t|T} \]
\[ W = \sum_{t=0}^{T-1} z_t \hat{x}_{t|T}^\top \qquad \Sigma_{zz} = \sum_{t=0}^{T-1} z_t z_t^\top \]
\[ F \leftarrow A B^{-1} \]
\[ Q \leftarrow \frac{C - F A^\top}{T - 1}, \quad Q \leftarrow \tfrac{1}{2}(Q + Q^\top) + \varepsilon I \]
\[ H \leftarrow W D^{-1} \]
\[ R \leftarrow \frac{\Sigma_{zz} - H W^\top}{T}, \quad R \leftarrow \tfrac{1}{2}(R + R^\top) + \varepsilon I \]
\[ \mu_0 \leftarrow \hat{x}_{0|T}, \quad P_0 \leftarrow P_{0|T} \]
The symmetrisation and ridge term \(\varepsilon I\) (\(\varepsilon = 10^{-6}\)) enforce that \(Q\) and \(R\) remain symmetric positive definite, preventing numerical collapse.
Matrix “division” — e.g. \(A
B^{-1}\) — is implemented as SolveSystem(B, A^T)^T
(Gaussian elimination) to avoid explicit matrix inversion.
After each EM iteration, compute the log-likelihood \(\ell(\theta)\) from the RTS smoother. Convergence is declared when
\[ |\ell^{(k)} - \ell^{(k-1)}| < \delta \]
for a user-supplied tolerance \(\delta\).
| Case | Time per iteration | Space (stack) | Notes |
|---|---|---|---|
| All | \(O(T \cdot N^3)\) | \(O(T \cdot N^2)\) | Dominated by RTS smoother forward/backward |
All storage is stack-allocated inside KalmanSmoother
(owned by ExpectationMaximization) and in local M-step
temporaries.
Consider \(N=1\), \(M=1\), \(T=3\), observations \(z=[0.5, 0.3, 0.8]\), initial guess \(F=1, H=1, Q=0.5, R=1, \mu_0=0, P_0=1\).
Iteration 1:
Repeat until \(|\ell^{(k)} - \ell^{(k-1)}| < \delta\) or maximum iterations reached.
SolveSystem may produce inaccurate results. Prefer \(T \gg N\).ExpectationMaximization
has no template type parameter. Dynamics values typically exceed the
\([-1, 1]\) range of Q15/Q31. Scale
your state variables into \([-1, 1]\)
and use a float wrapper if fixed-point EM is required.ExpectationMaximization owns a KalmanSmoother
instance. The smoother state is overwritten on every call to
Run(). Do not share an ExpectationMaximization
instance across threads.filters::KalmanSmoother: Provides the
complete E-step. ExpectationMaximization owns a
KalmanSmoother instance internally.filters::KalmanFilter: After EM
converges, pass the resulting EmParameters to
KalmanFilter::SetStateTransition(),
SetProcessNoise(), and SetMeasurementNoise()
for real-time deployment.solvers::GaussianElimination: Used
internally (via KalmanSmoother) for numerically stable
matrix solves in both the smoother and the M-step.Recursive Least Squares (RLS) is an online parameter estimation algorithm that incrementally updates a least-squares fit as new data arrives, without reprocessing past observations. It converges faster than gradient-descent methods (e.g., LMS) and is well-suited for real-time system identification and adaptive filtering on embedded systems.
Given a linear model \(y = \mathbf{x}^T \boldsymbol{\theta} + \epsilon\), where \(\mathbf{x} \in \mathbb{R}^p\) is the regressor, \(\boldsymbol{\theta} \in \mathbb{R}^p\) are the unknown parameters, and \(\epsilon\) is noise, RLS minimizes the exponentially weighted cost:
\[J(\boldsymbol{\theta}) = \sum_{k=1}^{n} \lambda^{n-k} (y_k - \mathbf{x}_k^T \boldsymbol{\theta})^2\]
where \(\lambda \in (0, 1]\) is the forgetting factor.
Given a new observation \((\mathbf{x}, y)\):
The implementation provides convergence diagnostics via
EstimationMetrics: - Innovation:
Pre-update prediction error magnitude - Residual:
Post-update prediction error magnitude - Uncertainty:
Trace of the covariance matrix \(P\)
(overall parameter uncertainty)
| Case | Time | Space | Notes |
|---|---|---|---|
| Average | \(O(p^2)\) | \(O(p^2)\) | Matrix-vector products + outer product |
Per-update cost is \(O(p^2)\) for \(p\) parameters, dominated by the covariance update. This is significantly cheaper than the \(O(p^3)\) batch solution via matrix inversion.
Estimating the slope of \(y = 2x\) with RLS (\(p = 2\): bias + slope, \(\lambda = 1\)):
| Step | \(\mathbf{x}\) | \(y\) | \(\hat{\theta}_0\) (bias) | \(\hat{\theta}_1\) (slope) | Innovation |
|---|---|---|---|---|---|
| Init | — | — | 0.0 | 0.0 | — |
| 1 | [1, 1] | 2 | 1.0 | 1.0 | 2.0 |
| 2 | [1, 2] | 4 | 0.0 | 2.0 | 1.0 |
| 3 | [1, 3] | 6 | 0.0 | 2.0 | 0.0 |
Convergence is achieved in 2 steps for this noiseless system.
State estimators such as the Kalman filter, Extended Kalman filter, and Unscented Kalman filter produce both a state estimate and a covariance matrix that quantifies estimation uncertainty. A filter is said to be consistent when the true errors are statistically compatible with the reported covariance — that is, the filter neither overestimates nor underestimates its own uncertainty.
Raw error metrics such as RMSE cannot distinguish a consistent filter (correct covariance) from an inconsistent one (wrong covariance that happens to produce low errors in a specific trial). Consistency metrics address this gap by normalising errors with respect to the predicted covariance and comparing the result against a chi-squared distribution.
Given the state error \(\varepsilon_k = x_k - \hat{x}_{k|k}\) and the posterior state covariance \(P_{k|k}\), the NEES at time step \(k\) is
\[\varepsilon_k^{\mathsf{T}} P_{k|k}^{-1} \varepsilon_k\]
Under the hypothesis that the filter is consistent and the errors are Gaussian, this quantity is chi-squared distributed with \(n_x\) degrees of freedom, where \(n_x\) is the state dimension.
Given the innovation \(\nu_k = z_k - \hat{z}_{k|k-1}\) and the innovation covariance \(S_k\), the NIS at time step \(k\) is
\[\nu_k^{\mathsf{T}} S_k^{-1} \nu_k\]
Under consistency, NIS follows a chi-squared distribution with \(n_z\) degrees of freedom, where \(n_z\) is the measurement dimension. Unlike NEES, NIS is computable without knowledge of the true state and is therefore usable in real deployments.
A two-sided 95% confidence region for a single NEES or NIS sample is
\[\chi^2_{n,\,0.025} \;\le\; \varepsilon_k^{\mathsf{T}} P_{k|k}^{-1} \varepsilon_k \;\le\; \chi^2_{n,\,0.975}\]
where \(\chi^2_{n,p}\) is the \(p\)-th quantile of the chi-squared distribution with \(n\) degrees of freedom.
Averaging NEES or NIS over \(N\) independent samples gives a statistic that is chi-squared with \(N \cdot n\) degrees of freedom, scaled by \(1/N\). The 95% bounds for the time-averaged value are therefore
\[\frac{\chi^2_{Nn,\,0.025}}{N} \;\le\; \bar{\varepsilon} \;\le\; \frac{\chi^2_{Nn,\,0.975}}{N}\]
Averaging reduces the variance of the consistency estimate and is the standard approach in Monte-Carlo filter evaluation.
Rather than forming \(P^{-1}\) explicitly, the quadratic form \(\varepsilon^{\mathsf{T}} P^{-1} \varepsilon\) is computed by solving \(P z = \varepsilon\) for \(z\) (via Gaussian elimination with partial pivoting) and then computing \(\varepsilon^{\mathsf{T}} z\). This avoids matrix inversion, reduces floating-point operations, and is numerically more stable.
Chi-squared quantiles for degrees of freedom 1–10 at the 95%
confidence level are stored in a compile-time array. Dimensions outside
this range are rejected via static_assert.
| Case | Time | Space | Notes |
|---|---|---|---|
| Best | \(O(n^2)\) | \(O(n^2)\) | Dominated by back-substitution in GE |
| Average | \(O(n^3)\) | \(O(n^2)\) | Gaussian elimination with partial pivoting |
| Worst | \(O(n^3)\) | \(O(n^2)\) | All pivots require row swaps |
All storage is stack-allocated; \(n\) is bounded at compile time by
Dim.
Consider a 2-D state (\(n = 2\)) with error \(\varepsilon = [1, 2]^{\mathsf{T}}\) and covariance \(P = \mathrm{diag}(1, 4)\).
A singular covariance matrix causes the linear solve to fail; the
implementation detects near-zero diagonal pivots and returns
std::nullopt. Callers must check the optional before using
the value.
A single sample NEES or NIS value has high variance under chi-squared; a filter may appear inconsistent simply due to random variation. Time-averaging over many Monte-Carlo runs is the statistically correct procedure.
NEES requires ground-truth state access and is therefore only applicable in simulation. NIS is the consistency monitor of choice for deployed systems.
The average NEES over a Monte-Carlo ensemble of \(M\) runs and \(K\) time steps yields \(M \cdot K\) samples; consistency must hold jointly across the ensemble, not just per run. Extensions to non-Gaussian cases (e.g., particle filters) use empirical quantiles rather than chi-squared bounds.
NEES and NIS are statistical companions to the standard Kalman filter
update step. They depend on the covariance propagation produced by the
filters/active family (KF, EKF, UKF). The linear solve
reuses solvers::GaussianElimination, and the state-error
representation aligns with
estimators::EstimationMetrics.
Real-time systems frequently need filters whose characteristics are unknown in advance or change over time. A fixed-coefficient FIR filter cannot cancel acoustic echo from an unknown loudspeaker-to-microphone path, equalize a time-varying channel, or identify the impulse response of a vibrating structure it has never seen before. The Least Mean Squares (LMS) algorithm and its normalized variant (NLMS) address this by adjusting a FIR filter’s coefficients online, one sample at a time, without requiring matrix inversions or covariance storage.
Let \(\mathbf{w} \in \mathbb{R}^{N}\) be the weight vector (FIR coefficients) and \(\mathbf{x}_n \in \mathbb{R}^{N}\) the tapped-delay-line vector at time \(n\), with \(x_n\) as the newest sample and \(x_{n-N+1}\) the oldest. The filter output is
\[y_n = \mathbf{w}_n^\top \mathbf{x}_n.\]
Given a desired signal \(d_n\), the estimation error is
\[e_n = d_n - y_n.\]
The instantaneous squared error \(J_n = e_n^2\) is a quadratic function of \(\mathbf{w}_n\). Its gradient with respect to \(\mathbf{w}\) is
\[\nabla J_n = -2 e_n \mathbf{x}_n.\]
LMS takes a noisy steepest-descent step using the instantaneous gradient:
\[\mathbf{w}_{n+1} = \mathbf{w}_n - \frac{\mu}{2} \nabla J_n = \mathbf{w}_n + \mu e_n \mathbf{x}_n.\]
NLMS divides the step by the input energy to make the effective step size input-level independent:
\[\mathbf{w}_{n+1} = \mathbf{w}_n + \frac{\mu}{\epsilon + \|\mathbf{x}_n\|^2} e_n \mathbf{x}_n,\]
where \(\epsilon > 0\) is a small regularizer that prevents division by zero during silence (\(\|\mathbf{x}_n\|^2 \approx 0\)).
Under the independence assumption (successive input vectors are uncorrelated), the expected weight vector converges to the Wiener solution \(\mathbf{w}^* = \mathbf{R}^{-1}\mathbf{p}\) (where \(\mathbf{R}\) is the input autocorrelation matrix and \(\mathbf{p}\) is the cross-correlation vector) provided
\[0 < \mu < \frac{2}{\lambda_{\max}(\mathbf{R})},\]
a sufficient condition being \(\mu < 2 / (N \cdot \sigma_x^2)\). For NLMS the stability range simplifies to \(0 < \mu < 2\).
| Case | Time | Space | Notes |
|---|---|---|---|
| Any | \(O(N)\) | \(O(N)\) | Two dot products + one AXPY, all length \(N\) |
No covariance matrix is stored; memory is exactly two vectors of length \(N\) (weights and delay line).
Consider a 2-tap system with true plant \(\mathbf{w}^* = [0.5,\ 0.5]^\top\), \(\mu = 0.1\), and a white input sequence.
| Step | Input | Delay line \(\mathbf{x}\) | Output \(y\) | Desired \(d\) | Error \(e\) | Weight update direction |
|---|---|---|---|---|---|---|
| 1 | 1.0 | [1.0, 0.0] | 0.0 | 0.5 | 0.5 | \(+0.05\mathbf{x}\) |
| 2 | 0.8 | [0.8, 1.0] | 0.04 | 0.9 | 0.86 | \(+0.086\mathbf{x}\) |
| … | … | … | … | … | … | converges to \(\mathbf{w}^*\) |
After many iterations the error approaches zero and the weights stabilize near \([0.5,\ 0.5]^\top\).
In any real system, sensors are noisy and models are imperfect. The Kalman filter answers a deceptively simple question: given a noisy measurement and an imperfect prediction, what is the best estimate of the true state?
The answer is an optimal, recursive, minimum-variance estimator for linear systems with Gaussian noise. At each time step it performs two operations:
The filter is computationally lightweight (matrix operations on small state vectors), requires no storage of past measurements, and adapts its confidence in real time via the covariance matrix.
Discrete-time linear system with process noise \(w\) and measurement noise \(v\):
\[x_k = F_{k-1} x_{k-1} + B_{k-1} u_{k-1} + w_{k-1}, \quad w \sim \mathcal{N}(0, Q)\] \[z_k = H_k x_k + v_k, \quad v \sim \mathcal{N}(0, R)\]
where: - \(x_k \in \mathbb{R}^n\) — state vector - \(z_k \in \mathbb{R}^m\) — measurement vector - \(F\) — state transition matrix (\(n \times n\)) - \(B\) — control input matrix (\(n \times l\)) - \(H\) — measurement matrix (\(m \times n\)) - \(Q\) — process noise covariance (\(n \times n\)) - \(R\) — measurement noise covariance (\(m \times m\))
\[\hat{x}_k^- = F_{k-1} \hat{x}_{k-1}\] \[P_k^- = F_{k-1} P_{k-1} F_{k-1}^T + Q_{k-1}\]
\[y_k = z_k - H_k \hat{x}_k^- \qquad \text{(innovation)}\] \[S_k = H_k P_k^- H_k^T + R_k \qquad \text{(innovation covariance)}\] \[K_k = P_k^- H_k^T S_k^{-1} \qquad \text{(Kalman gain)}\] \[\hat{x}_k = \hat{x}_k^- + K_k y_k \qquad \text{(state update)}\] \[P_k = (I - K_k H_k) P_k^- (I - K_k H_k)^T + K_k R_k K_k^T \qquad \text{(Joseph form covariance update)}\]
The Kalman filter is the minimum mean-square error (MMSE) estimator among all linear estimators. Under the assumption that \(w\) and \(v\) are Gaussian, it is also the MMSE among all estimators (linear or not).
| Operation | Time | Space | Notes |
|---|---|---|---|
| Predict | \(O(n^2)\) | \(O(n^2)\) | Matrix multiply \(F P F^T\) |
| Update | \(O(n^2 m + m^3)\) | \(O(nm)\) | Dominated by \(S^{-1}\) (\(m \times m\) inversion) and gain computation |
| Total per step | \(O(n^2 m + m^3)\) | \(O(n^2 + nm)\) | For \(m \ll n\), approximately \(O(n^2)\) |
For typical embedded use with \(n \leq 10\) and \(m \leq 5\), each step completes in microseconds.
System: Estimating position and velocity from noisy position measurements.
State: \(x = \begin{bmatrix} \text{position} \\ \text{velocity} \end{bmatrix}\), \(\Delta t = 1\) s.
\[F = \begin{bmatrix} 1 & 1 \\ 0 & 1 \end{bmatrix}, \quad H = \begin{bmatrix} 1 & 0 \end{bmatrix}, \quad Q = \begin{bmatrix} 0.1 & 0 \\ 0 & 0.1 \end{bmatrix}, \quad R = [1]\]
Initial: \(\hat{x}_0 = [0, 0]^T\), \(P_0 = \begin{bmatrix} 10 & 0 \\ 0 & 10 \end{bmatrix}\)
Measurement: \(z_1 = 1.2\) (true position = 1.0)
Predict:
\[\hat{x}_1^- = \begin{bmatrix} 1 & 1 \\ 0 & 1 \end{bmatrix} \begin{bmatrix} 0 \\ 0 \end{bmatrix} = \begin{bmatrix} 0 \\ 0 \end{bmatrix}\]
\[P_1^- = F P_0 F^T + Q = \begin{bmatrix} 20.1 & 10 \\ 10 & 10.1 \end{bmatrix}\]
Update:
\[y_1 = 1.2 - [1\; 0] \begin{bmatrix} 0 \\ 0 \end{bmatrix} = 1.2\]
\[S_1 = H P_1^- H^T + R = 20.1 + 1 = 21.1\]
\[K_1 = P_1^- H^T / S_1 = \begin{bmatrix} 20.1 \\ 10 \end{bmatrix} / 21.1 = \begin{bmatrix} 0.953 \\ 0.474 \end{bmatrix}\]
\[\hat{x}_1 = \begin{bmatrix} 0 \\ 0 \end{bmatrix} + \begin{bmatrix} 0.953 \\ 0.474 \end{bmatrix} \cdot 1.2 = \begin{bmatrix} 1.143 \\ 0.569 \end{bmatrix}\]
The filter quickly moves toward the measurement because the initial covariance \(P_0\) is large (low confidence). Over subsequent steps, \(P\) shrinks and the gain decreases — the filter becomes more reliant on the model.
| Variant | Key Difference |
|---|---|
| Extended Kalman Filter (EKF) | Linearizes nonlinear \(f(x)\) and \(h(x)\) around the current estimate |
| Unscented Kalman Filter (UKF) | Propagates sigma points through nonlinearities; avoids explicit Jacobians |
| Square-root Kalman Filter | Maintains \(\sqrt{P}\) instead of \(P\) for improved numerical stability |
| Information Filter | Works with the inverse covariance (information matrix); better for multi-sensor fusion |
| Kalman Smoother | Uses future measurements to refine past estimates (offline, non-causal) |
| Adaptive Kalman Filter | Estimates \(Q\) and \(R\) online from innovation statistics |
graph LR
KF["Kalman Filter"]
LQR["LQR Controller"]
DARE["DARE Solver"]
PID["PID Controller"]
KF -->|"state estimate"| LQR
DARE -->|"steady-state gain"| KF
KF -->|"filtered measurement"| PID
| Algorithm | Relationship |
|---|---|
| LQR Controller | The Kalman filter + LQR = LQG (Linear Quadratic Gaussian), the separation principle |
| DARE Solver | The steady-state Kalman gain can be found by solving a DARE (dual of the LQR DARE) |
| PID Controller | Kalman-filtered measurements can serve as the PID’s process variable input |
Many real-world systems are nonlinear: a pendulum’s restoring force is \(-\sin\theta\), not \(-\theta\); a radar measured range depends on \(\sqrt{x^2 + y^2}\), not a linear combination of states. The standard Kalman filter assumes linear dynamics \((x_k = Fx_{k-1})\) and cannot handle this directly.
The Extended Kalman Filter (EKF) extends the Kalman framework to nonlinear systems by linearizing the state transition and measurement functions around the current estimate at each step. It uses the same predict–update structure, but replaces the constant system matrices with Jacobians evaluated at the current operating point.
This makes the EKF the simplest and most widely-used nonlinear state estimator — at the cost of requiring the user to supply Jacobian functions and the assumption that linearization remains accurate within the filter’s uncertainty.
\[x_k = f(x_{k-1}, u_{k-1}) + w_{k-1}, \quad w \sim \mathcal{N}(0, Q)\] \[z_k = h(x_k) + v_k, \quad v \sim \mathcal{N}(0, R)\]
where \(f(\cdot)\) and \(h(\cdot)\) are arbitrary (differentiable) nonlinear functions.
\[F_k = \left.\frac{\partial f}{\partial x}\right|_{\hat{x}_{k-1}}\]
\[\hat{x}_k^- = f(\hat{x}_{k-1}, u_{k-1})\]
\[P_k^- = F_k P_{k-1} F_k^T + Q_{k-1}\]
\[H_k = \left.\frac{\partial h}{\partial x}\right|_{\hat{x}_k^-}\]
\[y_k = z_k - h(\hat{x}_k^-)\]
\[S_k = H_k P_k^- H_k^T + R_k\] \[K_k = P_k^- H_k^T S_k^{-1}\] \[\hat{x}_k = \hat{x}_k^- + K_k y_k\] \[P_k = (I - K_k H_k) P_k^-\]
| Operation | Time | Space | Notes |
|---|---|---|---|
| Jacobian eval | \(O(n^2)\) (user-defined) | \(O(n^2)\) | Depends on the specific nonlinear functions |
| Predict | \(O(n^2)\) | \(O(n^2)\) | Matrix multiply \(F P F^T\) plus nonlinear state propagation |
| Update | \(O(n^2 m + m^3)\) | \(O(nm)\) | Same as standard Kalman — the linearized equations are identical |
| Total per step | \(O(n^2 m + m^3)\) | \(O(n^2 + nm)\) | Dominated by Jacobian evaluation for complex models |
System: Estimating angle and angular velocity of a simple pendulum from noisy angle measurements.
State: \(x = \begin{bmatrix} \theta \\ \dot\theta \end{bmatrix}\), \(\Delta t = 0.1\) s.
Nonlinear dynamics: \[f(x) = \begin{bmatrix} \theta + \dot\theta \Delta t \\ \dot\theta - \sin(\theta) \Delta t \end{bmatrix}\]
Jacobian: \[F = \begin{bmatrix} 1 & \Delta t \\ -\cos(\theta) \Delta t & 1 \end{bmatrix}\]
Linear measurement: \(h(x) = \theta\), \(H = [1 \; 0]\).
At \(\hat{x}_0 = [0.3, 0]^T\): - Predict: \(\hat{x}_1^- = [0.3, -0.0296]^T\) (velocity decreases due to restoring force \(-\sin 0.3\)) - Update with \(z_1 = 0.28\): The Kalman gain weights the measurement against the prediction, pulling \(\theta\) toward 0.28.
After 50 iterations the EKF tracks the true pendulum oscillation closely, despite the nonlinear dynamics.
ControlSize template parameter.| Filter | Jacobian Required? | Accuracy for Nonlinear Systems | Computational Cost |
|---|---|---|---|
| Kalman Filter | No (linear only) | Exact for linear | Lowest |
| EKF | Yes | First-order approximation | Low |
| UKF | No | Second-order approximation | Moderate |
| Particle | No | Arbitrary (Monte Carlo) | High |
graph LR
EKF["Extended Kalman Filter"]
KF["Kalman Filter"]
UKF["Unscented Kalman Filter"]
EL["Euler-Lagrange"]
EKF -->|"linear limit"| KF
UKF -->|"alternative to"| EKF
EL -->|"plant model"| EKF
| Algorithm | Relationship |
|---|---|
| Kalman Filter | The EKF reduces to the standard KF when \(f\) and \(h\) are linear |
| Unscented Kalman Filter | Avoids Jacobians using sigma points; better for highly nonlinear cases |
| Euler-Lagrange | Provides the nonlinear dynamics model \(f(x)\) for mechanical systems |
The Extended Kalman Filter (EKF) linearizes nonlinearities using Jacobians. This works well for mild nonlinearities, but the first-order approximation can introduce significant errors when:
The Unscented Kalman Filter (UKF) addresses all three problems by replacing linearization with a deterministic sampling approach. Instead of approximating the nonlinear function, it approximates the probability distribution by choosing a small set of carefully weighted sample points — called sigma points — and propagating them through the true nonlinear function. The statistics (mean and covariance) are then reconstructed from the transformed points.
This captures the true mean and covariance to second-order accuracy for any nonlinearity, without computing a single Jacobian.
Given state estimate \(\hat{x} \in \mathbb{R}^n\) and covariance \(P\), generate \(2n+1\) sigma points:
\[\sigma_0 = \hat{x}\] \[\sigma_i = \hat{x} + \left(\sqrt{(n + \lambda) P}\right)_i, \quad i = 1, \ldots, n\] \[\sigma_{i+n} = \hat{x} - \left(\sqrt{(n + \lambda) P}\right)_i, \quad i = 1, \ldots, n\]
where \(\left(\sqrt{M}\right)_i\) denotes the \(i\)-th column of the matrix square root (Cholesky decomposition \(M = LL^T\), using columns of \(L\)), and:
\[\lambda = \alpha^2 (n + \kappa) - n\]
\[w_0^{(m)} = \frac{\lambda}{n + \lambda}, \quad w_0^{(c)} = \frac{\lambda}{n + \lambda} + (1 - \alpha^2 + \beta)\] \[w_i^{(m)} = w_i^{(c)} = \frac{1}{2(n + \lambda)}, \quad i = 1, \ldots, 2n\]
Parameters: - \(\alpha\): Controls spread of sigma points (\(10^{-4} \leq \alpha \leq 1\)). Smaller values keep points closer to the mean. - \(\beta\): Incorporates prior knowledge about the distribution (\(\beta = 2\) is optimal for Gaussian). - \(\kappa\): Secondary scaling parameter (typically \(\kappa = 0\) or \(\kappa = 3 - n\)).
\[\sigma_i^* = f(\sigma_i, u)\]
\[\hat{x}_k^- = \sum_{i=0}^{2n} w_i^{(m)} \sigma_i^*\] \[P_k^- = \sum_{i=0}^{2n} w_i^{(c)} (\sigma_i^* - \hat{x}_k^-)(\sigma_i^* - \hat{x}_k^-)^T + Q\]
Generate new sigma points from the predicted state \(\hat{x}_k^-\) and \(P_k^-\).
Transform sigma points through the measurement function:
\[z_i = h(\sigma_i)\]
\[\hat{z}_k = \sum_{i=0}^{2n} w_i^{(m)} z_i\] \[S_k = \sum_{i=0}^{2n} w_i^{(c)} (z_i - \hat{z}_k)(z_i - \hat{z}_k)^T + R\] \[P_{xz} = \sum_{i=0}^{2n} w_i^{(c)} (\sigma_i - \hat{x}_k^-)(z_i - \hat{z}_k)^T\]
\[K_k = P_{xz} S_k^{-1}\] \[\hat{x}_k = \hat{x}_k^- + K_k (z_k - \hat{z}_k)\] \[P_k = P_k^- - K_k S_k K_k^T\]
| Operation | Time | Space | Notes |
|---|---|---|---|
| Sigma point gen | \(O(n^3)\) | \(O(n^2)\) | Dominated by Cholesky decomposition |
| State propagation | \(O((2n+1) \cdot c_f)\) | \(O(n^2)\) | \(c_f\) = cost of evaluating \(f(x)\) |
| Mean/cov reconstruct | \(O(n^2 \cdot (2n+1))\) | \(O(n^2)\) | Weighted outer products |
| Update | \(O(n^2 m + m^3)\) | \(O(nm)\) | Same as standard Kalman for the gain computation |
| Total per step | \(O(n^3 + n^2 m)\) | \(O(n^2 + nm)\) | Cholesky and sigma point propagation dominate |
For \(n \leq 10\), the additional cost over EKF is small — and the elimination of Jacobian derivation is a significant engineering benefit.
System: Same pendulum as the EKF example, \(x = [\theta, \dot\theta]^T\), \(n = 2\), \(\Delta t = 0.1\) s.
Parameters: \(\alpha = 0.1\), \(\beta = 2\), \(\kappa = 0\) → \(\lambda = -1.98\).
Initial: \(\hat{x}_0 = [0.3, 0]^T\), \(P_0 = \text{diag}(0.5, 0.5)\).
The UKF naturally captures the curvature of \(-\sin\theta\) without computing \(\cos\theta\) — the sigma points “feel” the nonlinearity directly.
ControlSize template parameter.| Filter | Jacobian Required? | Accuracy Order | Handles High Nonlinearity? | Computational Cost |
|---|---|---|---|---|
| Kalman Filter | No (linear only) | Exact (linear) | No | Lowest |
| EKF | Yes | 1st order | Moderate | Low |
| UKF | No | 2nd order | Good | Moderate |
| Particle | No | Arbitrary | Excellent | High |
graph LR
UKF["Unscented Kalman Filter"]
KF["Kalman Filter"]
EKF["Extended Kalman Filter"]
CHO["Cholesky Decomposition"]
UKF -->|"linear limit"| KF
EKF -->|"alternative to"| UKF
CHO -->|"sigma points"| UKF
| Algorithm | Relationship |
|---|---|
| Kalman Filter | The UKF reduces to the standard KF when \(f\) and \(h\) are linear |
| Extended Kalman Filter | Uses Jacobians instead of sigma points; simpler but less accurate for nonlinear cases |
| Cholesky Decomposition | Used internally to generate sigma points from the covariance matrix |
The Kalman filter is a causal estimator: its state estimate at time \(t\) uses only observations up to and including time \(t\). When the full observation sequence is available (offline/batch setting), the Rauch-Tung-Striebel (RTS) smoother refines every filtered estimate by incorporating future measurements. The result is the minimum-mean-square-error (MMSE) state estimate given the entire sequence \(z_{1:T}\).
The RTS smoother is the prerequisite for the Expectation-Maximization (EM) algorithm for Kalman Filter parameter identification: the E-step requires smoothed means, smoothed covariances, and the lag-1 cross-covariance \(P_{t,t-1|T}\) in order to form the sufficient statistics used by the M-step.
\[ x_{t+1} = F x_t + w_t, \quad w_t \sim \mathcal{N}(0, Q) \]
\[ z_t = H x_t + v_t, \quad v_t \sim \mathcal{N}(0, R) \]
with \(x_0 \sim \mathcal{N}(\mu_0, P_0)\).
For \(t = 0, 1, \ldots, T-1\):
Initialise (t = 0 only):
\[\hat{x}_{0|-} = \mu_0, \quad P_{0|-} = P_0\]
Update:
\[ S_t = H P_{t|-} H^\top + R \]
\[ K_t = P_{t|-} H^\top S_t^{-1} \]
\[ \hat{x}_{t|t} = \hat{x}_{t|-} + K_t (z_t - H \hat{x}_{t|-}) \]
\[ P_{t|t} = (I - K_t H) P_{t|-} (I - K_t H)^\top + K_t R K_t^\top \quad \text{(Joseph form)} \]
Log-likelihood contribution:
\[ \ell_t = -\tfrac{1}{2}\bigl(\log\det S_t + (z_t - H\hat{x}_{t|-})^\top S_t^{-1} (z_t - H\hat{x}_{t|-}) + m\log 2\pi\bigr) \]
where \(m\) =
MeasurementSize. The total is \(\ell = \sum_{t=0}^{T-1} \ell_t\).
Predict (for \(t < T-1\)):
\[ \hat{x}_{t+1|-} = F \hat{x}_{t|t}, \quad P_{t+1|-} = F P_{t|t} F^\top + Q \]
Initialise at \(t = T-1\):
\[ \hat{x}_{T-1|T} = \hat{x}_{T-1|T-1}, \quad P_{T-1|T} = P_{T-1|T-1} \]
For \(t = T-2, T-3, \ldots, 0\):
Smoother gain:
\[ G_t = P_{t|t} F^\top P_{t+1|t}^{-1} \]
Smoothed mean and covariance:
\[ \hat{x}_{t|T} = \hat{x}_{t|t} + G_t (\hat{x}_{t+1|T} - \hat{x}_{t+1|t}) \]
\[ P_{t|T} = P_{t|t} + G_t (P_{t+1|T} - P_{t+1|t}) G_t^\top \]
The EM M-step requires \(P_{t,t-1|T} = \mathrm{Cov}(x_t, x_{t-1} \mid z_{1:T})\).
Initialisation (Shumway & Stoffer, 1982):
\[ P_{T-1, T-2|T} = (I - K_{T-1} H) F P_{T-2|T-2} \]
Recursion for \(t = T-3, \ldots, 0\):
\[ P_{t+1, t|T} = P_{t+1|t+1} G_t^\top + G_{t+1} (P_{t+2, t+1|T} - F P_{t+1|t+1}) G_t^\top \]
Note: \(P_{0,-1|T}\) is undefined;
index 0 of lagCrossCovariances is always zero.
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(T \cdot N^3)\) | \(O(T \cdot N^2)\) stack | Dominant cost: \(N\times N\) solves at each step |
All storage is stack-allocated via std::array. There is
no heap usage.
Consider a 1-D constant-position model (\(N=1\), \(M=1\), \(T=3\)) with \(F=1\), \(H=1\), \(Q=0.1\), \(R=1\), \(\mu_0=0\), \(P_0=1\), and observations \(z = [0.5, 0.3, 0.8]\).
Forward pass:
| \(t\) | \(P_{t\|-}\) | \(S_t\) | \(K_t\) | \(\hat{x}_{t\|t}\) | \(P_{t\|t}\) |
|---|---|---|---|---|---|
| 0 | 1.0 | 2.0 | 0.500 | 0.250 | 0.500 |
| 1 | 0.600 | 1.600 | 0.375 | 0.363 | 0.375 |
| 2 | 0.475 | 1.475 | 0.322 | 0.620 | 0.322 |
Backward pass (\(t=1\) then \(t=0\)):
\(t=1\): \(G_1 = 0.500/0.600 = 0.833\); \(\hat{x}_{1|3} = 0.363 + 0.833(0.620 - 0.363) \approx 0.577\)
\(t=0\): \(G_0 = 0.375/0.375 = 1.000\); \(\hat{x}_{0|3} = 0.250 + 1.000(0.577 - 0.363) \approx 0.464\)
Notice how the smoothed \(\hat{x}_{0|3}=0.464\) incorporates all three observations, whereas the filtered \(\hat{x}_{0|0}=0.250\) only used the first.
SolveSystem (Gaussian elimination with partial
pivoting) rather than explicit matrix inversion. If the predicted
covariance becomes near-singular (possible when \(Q \approx 0\)), numerical accuracy
degrades. Ensure \(Q\) has positive
diagonal entries.lagCrossCovariances[0] is always zero:
The lag-1 cross-covariance for index 0 (\(P_{0,-1|T}\)) is undefined. The first valid
entry is index 1 (\(P_{1,0|T}\)).std::size_t; the break guard if (t == 0) break
prevents unsigned wrap-around.std::array members dominate stack usage. Choose
MaxSteps conservatively on resource-constrained
targets.ExpectationMaximization to compute the sufficient
statistics \(\{A, B, C, D, W,
\Sigma_{zz}\}\) for the M-step.filters::KalmanFilter: The forward
pass of KalmanSmoother replicates the KF update equations.
The smoother is applied after the filter, not instead
of it.estimators::ExpectationMaximization:
The smoother is owned and invoked by
ExpectationMaximization as its E-step. Users who only need
EM should interact with ExpectationMaximization
directly.solvers::GaussianElimination: Used for
all matrix “division” operations (smoother gain, Kalman gain) to avoid
explicit matrix inversion.solvers::CholeskyDecomposition: Used
to compute \(\log \det S_t\) for the
log-likelihood.Any system that needs to know its 3-D orientation in space — a drone, a robot arm, an AR headset, a wearable device — must fuse data from multiple sensors. A gyroscope measures angular rate with high bandwidth and low short-term noise, but its integral drifts over time due to bias. An accelerometer measures the gravity vector, which gives a long-term absolute reference for pitch and roll, but it is contaminated by vibration. A magnetometer provides a heading reference for yaw, but it is affected by magnetic interference.
The Attitude and Heading Reference System (AHRS) filter solves the drift-correction problem in O(1) time per step with a fixed, small memory footprint. Two closely related algorithms — Madgwick’s gradient-descent filter and Mahony’s passive complementary filter — achieve this by continuously nudging the gyro-integrated quaternion so that the predicted sensor directions match the measured ones. Both are far cheaper to compute than a full quaternion Extended Kalman Filter, making them the standard choice for microcontroller-class attitude estimation.
Orientation is maintained as a unit quaternion \(q = [q_w, q_x, q_y, q_z]^T \in \mathbb{H}\), \(\|q\| = 1\), mapping from the body frame to the Earth frame. Quaternions avoid the gimbal lock inherent in Euler angles and require fewer trigonometric operations than rotation matrices per integration step.
The pure gyro propagation step integrates the body angular rate \(\boldsymbol{\omega} = [\omega_x, \omega_y, \omega_z]^T\) in rad/s:
\[\dot{q} = \frac{1}{2} q \otimes \begin{bmatrix} 0 \\ \boldsymbol{\omega} \end{bmatrix}\]
\[q_{k+1} = q_k + \dot{q} \, T_s\]
followed by renormalization. This is the predict step; without a correction it drifts.
Define the objective function as the alignment error between the predicted sensor directions and the measurements. For the gravity observation:
\[\mathbf{f}(q, \hat{\mathbf{a}}) = R(q)^T \mathbf{g}_{\text{ref}} - \hat{\mathbf{a}}\]
where \(\mathbf{g}_{\text{ref}} = [0, 0, 1]^T\) and \(\hat{\mathbf{a}}\) is the normalized accelerometer vector. The steepest-descent direction in quaternion space is:
\[\nabla F = J^T \mathbf{f}\]
where \(J = \partial \mathbf{f}/\partial q\) is the \(3 \times 4\) Jacobian of \(\mathbf{f}\) with respect to \(q\). This gradient is normalized and subtracted from the gyro-driven rate:
\[\dot{q} = \frac{1}{2} q \otimes \begin{bmatrix} 0 \\ \boldsymbol{\omega} \end{bmatrix} - \beta \, \frac{\nabla F}{\|\nabla F\|}\]
The parameter \(\beta\) is the gradient-descent step size; it is set proportional to the expected gyro measurement error in rad/s.
For the magnetometer (MARG mode), the earth frame reference is \(\mathbf{b} = [b_x, 0, b_z]^T\), where \(b_x\) and \(b_z\) are computed by rotating the normalized magnetometer measurement into the Earth frame and zeroing its \(y\)-component, making the heading reference dip-angle-agnostic. A second objective function \(\mathbf{f}_\text{mag}\) and its Jacobian are added to the gradient.
Rather than gradient descent, Mahony’s filter uses a cross-product error:
\[\mathbf{e} = \hat{\mathbf{a}} \times \mathbf{v}\]
where \(\mathbf{v}\) is the third column of \(R(q)\) (the predicted gravity direction in the body frame). The angular rate is corrected before integration:
\[\boldsymbol{\omega}_c = \boldsymbol{\omega} + K_p \mathbf{e} + \mathbf{b}_\text{est}\]
\[\dot{\mathbf{b}}_\text{est} = K_i \mathbf{e}\]
The integral term \(\mathbf{b}_\text{est}\) is a running estimate of the gyro bias; once it converges, the steady-state attitude error is driven to zero even under sustained gyro drift. The proportional gain \(K_p\) sets the bandwidth of the correction loop; \(K_i\) sets the bias-learning rate.
For MARG mode a magnetometer cross-product error is added to \(\mathbf{e}\):
\[\mathbf{e} = \hat{\mathbf{a}} \times \mathbf{v} + \hat{\mathbf{m}} \times \mathbf{w}\]
where \(\mathbf{w}\) is the predicted earth-field direction in the body frame from the current tilt.
Both algorithms renormalize \(q\) after every integration step to enforce the unit-norm constraint, compensating for the first-order Euler integration error that would otherwise slowly push \(q\) off the unit sphere.
| Case | Time | Space | Notes |
|---|---|---|---|
| UpdateImu | O(1) | O(1) | Fixed multiply-add count; one inverse-sqrt normalization |
| UpdateMarg | O(1) | O(1) | Two objective/gradient evaluations; same asymptotic cost |
| Memory | — | 7 T | 4 quaternion + 3 integral bias floats; no buffers or heap |
The fixed cost makes both algorithms suitable for any loop rate the MCU can sustain, from 100 Hz audio-rate IMUs to 8 kHz flight-controller IMUs.
Scenario: Quadrotor is hovering level. Gyro measures a small constant bias of 0.05 rad/s on the x-axis. Accelerometer reads \([0, 0, 9.81]\) m/s².
Madgwick step (simplified):
Over time without correction this drift accumulates; with the gradient term driving \(\mathbf{f} \to 0\), Madgwick continuously nudges \(q\) back to level.
Mahony step (simplified):
| Variant | Key Difference |
|---|---|
| 6-DOF (IMU-only) | Accelerometer alone; roll and pitch converge, yaw is unobservable |
| 9-DOF (MARG) | Adds magnetometer; all three angles converge given a non-disturbed field |
| Extended Kalman AHRS | Treats noise covariances explicitly; heavier but allows systematic tuning via \(Q\)/\(R\) matrices |
| Multiplicative EKF (MEKF) | Kalman update on the error quaternion to preserve unit-norm; best-in-class accuracy, high cost |
| Gradient-descent with adaptive β | Adjusts \(\beta\) based on the magnitude of the gradient, reducing transient overshoot at startup |
| Second-order Runge-Kutta integration | Reduces integration error at low update rates at the cost of one extra function evaluation |
| Algorithm | Relationship |
|---|---|
| Complementary Filter | The scalar 1-D ancestor; Madgwick/Mahony extend the idea to quaternion SO(3) |
| Extended Kalman Filter | The probabilistic alternative; heavier but allows noise covariance estimation |
| Quaternion | The state representation shared by all three-axis attitude estimators |
In embedded control and tracking applications, a sensor delivers a position measurement every sample period, but that measurement is corrupted by noise. A simple lowpass filter smooths the noise but cannot estimate velocity, which is needed for prediction and control. A full Kalman filter computes optimal gains but requires covariance propagation — a matrix inverse every step — which is too expensive for a fast ISR.
The alpha-beta (and its extension, alpha-beta-gamma) filter resolves this tension. It maintains a position and velocity estimate (and optionally acceleration) using only a few multiply-adds per sample. The gains are fixed constants, computed once at design time from a single scalar parameter. The result is a deterministic, constant-time predictor-corrector that delivers most of the benefit of a steady-state Kalman filter at a fraction of the cost.
The filter assumes constant-velocity (order 2) or constant-acceleration (order 3) kinematics. For order 2, the state is \(\mathbf{x} = [p, \dot{p}]^\top\); for order 3, \(\mathbf{x} = [p, \dot{p}, \ddot{p}]^\top\).
\[\hat{p}^- = \hat{p} + T_s \hat{v} + \tfrac{1}{2} T_s^2 \hat{a} \quad (\hat{a} \text{ omitted for order 2})\] \[\hat{v}^- = \hat{v} + T_s \hat{a} \quad (\hat{a} \text{ omitted for order 2})\] \[\hat{a}^- = \hat{a}\]
Let the innovation (residual) be \(r = z - \hat{p}^-\), where \(z\) is the measured position. Then:
\[\hat{p} = \hat{p}^- + \alpha r\] \[\hat{v} = \hat{v}^- + \frac{\beta}{T_s} r\] \[\hat{a} = \hat{a}^- + \frac{2\gamma}{T_s^2} r \quad (\text{order 3 only})\]
The denominators \(T_s\) and \(T_s^2\) convert the dimensionless residual into velocity and acceleration corrections.
For the order-2 case, Kalata (1984) defines the tracking index \(\lambda = \frac{\sigma_w T_s^2}{\sigma_v}\), where \(\sigma_w\) is process noise intensity and \(\sigma_v\) is measurement noise standard deviation. The critically-damped gains are:
\[r = \frac{4 + \lambda - \sqrt{8\lambda + \lambda^2}}{4}\] \[\alpha = 1 - r^2\] \[\beta = 2(2 - \alpha) - 4\sqrt{1 - \alpha}\]
A single scalar \(\lambda\) thus controls the smoothing/lag trade-off.
For the order-2 filter, Simpson’s triangle requires:
\[0 < \alpha < 1, \qquad 0 < \beta < 4 - 2\alpha\]
Violation of the second bound causes oscillatory divergence.
| Case | Time | Space | Notes |
|---|---|---|---|
| Best | \(O(1)\) | \(O(N)\) | \(N \in \{2, 3\}\) state words plus fixed scalars |
| Average | \(O(1)\) | \(O(N)\) | same |
| Worst | \(O(1)\) | \(O(N)\) | gains are precomputed; no covariance update |
The hot path is a handful of fused multiply-add operations: predict costs 2–4 MACs, correct costs 2–3 MACs.
Consider an order-2 filter with \(\alpha = 0.5\), \(\beta = 0.1\), \(T_s = 1.0\,\text{s}\), measuring a ramp \(z[n] = 0.2n\).
| Step | \(z\) | \(\hat{p}^-\) | \(\hat{v}^-\) | \(r\) | \(\hat{p}\) | \(\hat{v}\) |
|---|---|---|---|---|---|---|
| 0 (seed) | 0.0 | — | — | — | 0.0 | 0.0 |
| 1 | 0.2 | 0.0 | 0.0 | 0.2 | 0.10 | 0.020 |
| 2 | 0.4 | 0.12 | 0.020 | 0.28 | 0.26 | 0.048 |
| … | … | … | … | … | … | … |
After several hundred steps, \(\hat{v} \to 0.2\) and lag \(\to 0\).
Real-time attitude and heading estimation requires fusing two fundamentally different sensor modalities: a gyroscope that integrates angular rate to produce a short-term angle estimate (fast response, low noise, but subject to drift) and an accelerometer or magnetometer that reads the angle directly (accurate at rest, noisy during motion, slow dynamics). Neither sensor alone is sufficient. The complementary filter solves the fusion problem with two multiplies and two adds per sample, making it the default tilt estimator on virtually every IMU-based embedded project where a full Kalman filter is unaffordable.
The two sensor paths are complementary in the transfer-function sense: the gyro path acts as a first-order high-pass filter and the direct-angle path acts as a first-order low-pass filter sharing the same crossover frequency \(\omega_c = 1/\tau\). Their sum is identically unity for all frequencies:
\[H_{HP}(s) + H_{LP}(s) = 1\]
This ensures that no frequency content is amplified or attenuated by the fusion itself.
Given the fused angle \(\theta_k\), gyro rate \(\omega_k\), accelerometer angle \(\theta_{acc,k}\), sample period \(T_s\), and blend weight \(\alpha \in [0,1]\):
\[\theta_{k+1} = \alpha\,(\theta_k + \omega_k\,T_s) + (1-\alpha)\,\theta_{acc,k}\]
The term \(\theta_k + \omega_k T_s\) is the high-pass path (integration of the fast sensor), and \(\theta_{acc,k}\) is the low-pass path (direct measurement from the slow sensor).
The single tuning knob is the crossover time constant \(\tau\), which maps to \(\alpha\) via:
\[\alpha = \frac{\tau}{\tau + T_s}\]
Below \(1/\tau\) the filter trusts the accelerometer; above it, the gyroscope. Typical embedded values are \(\tau = 0.5\)–\(2\) s, corresponding to \(\alpha \approx 0.98\) at \(T_s = 10\) ms.
When the state is a heading angle in \((-\pi, \pi]\), a naive linear blend can jump by \(2\pi\) near the seam. Instead the blend is performed along the shortest arc:
\[\delta = \mathrm{WrapToPi}(\theta_{acc} - \hat{\theta})\] \[\theta_{k+1} = \mathrm{WrapToPi}\!\left(\hat{\theta} + (1-\alpha)\,\delta\right)\]
where \(\mathrm{WrapToPi}(x) = \bigl((x + \pi) \bmod 2\pi\bigr) - \pi\).
| Operation | Time | Space | Notes |
|---|---|---|---|
| Update | \(O(1)\) | \(O(1)\) | 2 multiplies, 2 adds; 1 fmod when wrapping |
| Reset | \(O(1)\) | \(O(1)\) | Single state write |
Total storage: one angle word plus two constant coefficients.
Setup: \(\alpha = 0.98\), \(T_s = 10\) ms, initial angle \(= 0\).
Sample 1: gyro rate \(\omega = 10\) deg/s \(= 0.1745\) rad/s, accel reads \(\theta_{acc} = 0.01\) rad.
\[\hat{\theta} = 0 + 0.1745 \times 0.01 = 0.001745 \text{ rad} \quad (\text{gyro path})\] \[\theta_1 = 0.98 \times 0.001745 + 0.02 \times 0.01 = 0.001710 + 0.000200 = 0.001910 \text{ rad}\]
After many samples with zero rate and accel \(= 0.2\) rad: the low-pass term accumulates and \(\theta \to 0.2\) rad as \((1-\alpha)^n \to 0\).
| Variant | Key Difference |
|---|---|
| Mahony filter | 3-D quaternion formulation with integral gyro-bias estimator |
| Madgwick filter | Gradient-descent quaternion fusion; no linearisation |
| Alpha-Beta filter | Fixed-gain tracking without a slow sensor; pure high-pass integration |
| Kalman filter | Optimal (minimum-variance) fusion; requires noise covariance tuning |
| Two-step complementary | Separate pitch/roll from heading; common on 6-DOF IMUs |
| Algorithm | Relationship |
|---|---|
| Exponential Moving Average | The low-pass path in isolation; \(\alpha_{EMA} = 1-\alpha_{CF}\) |
| Alpha-Beta Filter | Complementary filter without a slow-sensor reference; fixed-gain tracker |
| AHRS Madgwick/Mahony | 3-D quaternion generalization with gyro-bias estimation |
| Kalman Filter | Statistically optimal generalization requiring \(Q\) and \(R\) tuning |
High-order IIR filters realized as a single monolithic direct-form transfer function suffer from severe numerical sensitivity: small perturbations in any coefficient can shift poles dramatically, and round-off noise accumulates in proportion to the filter order. These effects are catastrophic on fixed-point hardware and non-trivial even in single-precision floating-point.
The solution adopted universally in professional DSP is to factor the transfer function into quadratic terms — each with exactly two poles and two zeros — and chain them in series. This cascade of second-order sections (“biquads”) keeps each section’s coefficients well-scaled, its pole-sensitivity small, and its round-off noise bounded independently of the total filter order.
Any real-coefficient rational transfer function of order \(N\) factors as:
\[H(z) = G \prod_{k=1}^{\lceil N/2 \rceil} \frac{b_{0,k} + b_{1,k}z^{-1} + b_{2,k}z^{-2}}{1 + a_{1,k}z^{-1} + a_{2,k}z^{-2}}\]
Each factor is a second-order section (SOS). The overall frequency response is the product of the per-section responses; the total group delay is the sum of per-section group delays.
For standard shelving, peaking, and pass/reject types, closed-form normalized coefficients derive from the bilinear transform applied to an analog prototype. Define:
\[\omega_0 = \frac{2\pi f_c}{f_s}, \qquad c_\omega = \cos(\omega_0), \qquad \alpha = \frac{\sin(\omega_0)}{2Q}\]
Low-pass:
\[b_0 = \frac{1 - c_\omega}{2(1+\alpha)}, \quad b_1 = \frac{1-c_\omega}{1+\alpha}, \quad b_2 = b_0\]
\[a_1 = \frac{-2c_\omega}{1+\alpha}, \quad a_2 = \frac{1-\alpha}{1+\alpha}\]
High-pass: replace \((1 - c_\omega)\) with \((1 + c_\omega)\) and negate \(b_1\).
Notch (band-reject):
\[b_0 = b_2 = \frac{1}{1+\alpha}, \quad b_1 = a_1 = \frac{-2c_\omega}{1+\alpha}, \quad a_2 = \frac{1-\alpha}{1+\alpha}\]
All coefficients are pre-normalized by \(a_0 = 1 + \alpha\), so the feedback denominator leading coefficient is always 1 and the hot path requires no division.
TDF-II is the canonical embedded realization. It minimizes the number of state registers to two per section while achieving low round-off noise. The recurrence for section \(k\) is:
\[y[n] = b_0 x[n] + z_1[n-1]\]
\[z_1[n] = b_1 x[n] - a_1 y[n] + z_2[n-1]\]
\[z_2[n] = b_2 x[n] - a_2 y[n]\]
This is exactly five multiplies and four adds per sample — the theoretical minimum for an arbitrary biquad — and requires no intermediate storage beyond \(z_1\) and \(z_2\).
| Case | Time | Space | Notes |
|---|---|---|---|
| Per sample | \(O(S)\) | \(O(S)\) | \(S\) = number of sections; 5 mults + 4 adds each |
| Design | \(O(1)\) | \(O(1)\) | Closed-form RBJ formulas; no iteration |
| Reset | \(O(S)\) | — | Zero two state words per section |
Example: design a 4th-order low-pass as two cascaded 2nd-order sections.
Given \(f_c = 100\) Hz, \(f_s = 1000\) Hz, \(Q = 0.707\) (Butterworth factor for each section):
Impulse response trace for bypass section \(\{b_0=1, b_1=b_2=a_1=a_2=0\}\): Input \([1, 0, 0, \ldots]\) → output \([1, 0, 0, \ldots]\) — identity passthrough.
math::RecursiveBuffer for time-domain state; the biquad
avoids the overhead by keeping only two explicit state scalars per
section.Decimation and interpolation in digital signal processing typically require a lowpass anti-aliasing filter before the rate change. Standard FIR filters require multiplications proportional to their order. The CIC filter achieves a highly efficient lowpass response using only additions and subtractions, making it ideal for resource-constrained embedded systems where multipliers are expensive or unavailable.
CIC filters are used in sigma-delta ADC interfaces, software-defined radio front-ends, and any application that must drastically reduce the sample rate of a high-frequency signal stream before further processing.
A CIC decimator of order \(N\) with decimation ratio \(R\) and differential delay \(M\) is defined by its \(z\)-domain transfer function:
\[H(z) = \left( \frac{1 - z^{-RM}}{1 - z^{-1}} \right)^N\]
The numerator factor \(1 - z^{-RM}\) is the \(z\)-transform of the comb (differencing) stage. The denominator \(\frac{1}{1 - z^{-1}}\) is the accumulator (integrator) stage.
The filter consists of two cascaded sections:
Integrator section (running at the high input rate \(f_s\)): \(N\) stages of first-order IIR accumulators,
\[y_k[n] = y_k[n-1] + y_{k-1}[n], \quad k = 1, \ldots, N\]
Comb section (running at the low output rate \(f_s / R\)): \(N\) stages of differencing with delay \(M\),
\[y_k[m] = y_{k-1}[m] - y_{k-1}[m - M], \quad k = 1, \ldots, N\]
The unnormalized DC gain of the filter is:
\[G = (R \cdot M)^N\]
All outputs are divided by \(G\) to normalize the DC gain to unity for a constant input.
The magnitude response in the baseband is approximately:
\[|H(f)| = \left| \frac{\sin(\pi f M R / f_s)}{R \sin(\pi f / f_s)} \right|^N\]
This is a sinc-like response that suppresses high-frequency content before the rate change.
| Case | Time per input sample | Space | Notes |
|---|---|---|---|
| Best | \(O(N)\) | \(O(N \cdot M)\) | \(N\) integrator ops; comb only at decimation points |
| Average | \(O(N)\) | \(O(N \cdot M)\) | Same |
| Worst | \(O(N)\) | \(O(N \cdot M)\) | Comb adds \(N\) differencing ops at rate \(f_s/R\) |
The integrator section executes \(N\) additions per input sample. The comb section executes \(N\) subtractions once every \(R\) input samples. There are no multiplications in the signal path.
Consider \(N=2\), \(R=4\), \(M=1\), input impulse \(x[0]=1\), \(x[n]=0\) for \(n>0\).
Integrator section at \(n=0,1,2,3\):
| \(n\) | Input | Integrator 1 | Integrator 2 |
|---|---|---|---|
| 0 | 1 | 1 | 1 |
| 1 | 0 | 1 | 2 |
| 2 | 0 | 1 | 3 |
| 3 | 0 | 1 | 4 |
Comb section at decimated sample \(m=0\) (triggered at \(n=3\)):
At \(m=1\) (triggered at \(n=7\)):
Integer overflow in fixed-point implementations: in fixed-point arithmetic, the integrators accumulate without bound between comb operations. Registers must be wide enough to hold \((R \cdot M)^N\) times the maximum input value. This implementation uses floating-point, which avoids this issue.
Initial transient: the filter takes several R-length blocks to settle to steady-state behavior for a constant input. DC normalization is exact only after the delay pipeline is fully flushed.
Passband droop: the sinc-shaped response causes attenuation even near DC as the input frequency increases. Compensation filters or a larger \(R\) reduce in-band droop.
Aliasing from high-order terms: if the input signal has energy above \(f_s / (2R)\), aliased components will appear at the output. A simple prefilter can reduce this.
Interpolating CIC: the comb section runs at the low rate and the integrators at the high rate, acting as an upsampler. The architecture mirrors the decimator with sections swapped.
Pruned CIC: removes multiplier-free adder stages that contribute negligibly to the response, reducing hardware at the cost of response shape.
Compensation filter: a short linear-phase FIR appended at the low rate corrects passband droop without reintroducing multipliers in the high-rate path.
Variable-rate CIC: by making \(R\) a runtime parameter, one filter structure supports multiple decimation ratios, useful in SDR front-ends.
A CIC filter of order \(N=1\), \(M=1\) is equivalent to a boxcar (rectangular window) FIR of length \(R\), identical to a Moving Average filter operating on non-overlapping blocks. The Moving Average filter in this library is the continuous-output counterpart.
Higher-order CICs approximate a Gaussian response as \(N \to \infty\), connecting them to the Gaussian filter family in theory.
The CIC is a special case of the more general Hogenauer filter structure, which can be pruned to reduce word widths at each stage.
Sensor data in embedded systems is noisy. A full moving-average buffer costs O(N) RAM and O(N) computation per sample. The exponential moving average (EMA), also called a one-pole recursive low-pass filter or leaky integrator, achieves useful smoothing with a single state word and one multiply-accumulate per sample. It is the minimal-cost filter that tracks a slowly varying DC level while suppressing high-frequency noise.
Given a smoothing factor \(\alpha \in (0, 1]\) and a discrete input sequence \(x[n]\), the EMA output \(y[n]\) obeys the first-order linear recurrence:
\[y[n] = \alpha \, x[n] + (1 - \alpha) \, y[n-1]\]
with \(y[-1] = y_0\) (the initial state, often zero).
Expanding the recurrence:
\[y[n] = y[n-1] + \alpha \bigl(x[n] - y[n-1]\bigr)\]
This form has one subtraction and one multiply-accumulate; it avoids computing \((1-\alpha)\) explicitly and keeps the error term \((x[n] - y[n-1])\) small, which is numerically preferable in fixed-point arithmetic.
The transfer function in the \(z\)-domain is:
\[H(z) = \frac{\alpha}{1 - (1-\alpha)\,z^{-1}}\]
The single pole is at \(z = 1 - \alpha\). For \(\alpha \in (0, 1]\) the pole lies in \([0, 1)\), which is strictly inside the unit circle, so the filter is unconditionally stable.
Setting \(z = 1\) (DC, \(\omega = 0\)):
\[H(1) = \frac{\alpha}{1 - (1-\alpha)} = 1\]
Unity DC gain: a constant input converges exactly to that constant.
Matching the EMA pole to a first-order RC low-pass filter with time constant \(\tau = 1/(2\pi f_c)\) and sample period \(T_s = 1/f_s\):
\[\alpha = \frac{T_s}{\tau + T_s} = \frac{T_s}{\frac{1}{2\pi f_c} + T_s}\]
The impulse response is \(h[n] = \alpha (1-\alpha)^n \, u[n]\), which decays geometrically — hence the name “exponential” moving average.
| Case | Time | Space | Notes |
|---|---|---|---|
| Best | O(1) | O(1) | Single MAC per sample, one state word |
| Average | O(1) | O(1) | Identical regardless of input |
| Worst | O(1) | O(1) | No data-dependent branches on hot path |
Suppose \(\alpha = 0.5\), \(y[-1] = 0\), and the input is the unit impulse \(x = [1, 0, 0, 0, \ldots]\).
| \(n\) | \(x[n]\) | \(x[n] - y[n-1]\) | \(\alpha \cdot \Delta\) | \(y[n]\) |
|---|---|---|---|---|
| 0 | 1 | \(1 - 0 = 1\) | \(0.5\) | \(0.5\) |
| 1 | 0 | \(0 - 0.5 = -0.5\) | \(-0.25\) | \(0.25\) |
| 2 | 0 | \(0 - 0.25 = -0.25\) | \(-0.125\) | \(0.125\) |
| 3 | 0 | \(0 - 0.125 = -0.125\) | \(-0.0625\) | \(0.0625\) |
Each output is half the previous, confirming \(h[n] = (0.5)^{n+1}\).
A Finite Impulse Response (FIR) filter is a discrete-time filter whose impulse response has a finite duration. FIR filters are inherently stable, have linear phase properties (when symmetric), and are straightforward to design and implement. They are the default choice for applications requiring guaranteed stability and predictable phase behavior.
A causal FIR filter of order \(N-1\) (with \(N\) taps) computes the output as a weighted sum of the current and past inputs (discrete convolution):
\[y[n] = \sum_{i=0}^{N-1} b_i \cdot x[n-i]\]
where: - \(b_i\) are the filter coefficients (tap weights) - \(x[n-i]\) are the current and \(N-1\) most recent input samples - \(N\) is the number of taps (filter length)
In the z-domain, the FIR filter transfer function is a polynomial in \(z^{-1}\):
\[H(z) = \sum_{i=0}^{N-1} b_i z^{-i}\]
Since the denominator is unity, FIR filters have all poles at the origin and are always stable.
Evaluating on the unit circle (\(z = e^{j\omega}\)):
\[H(e^{j\omega}) = \sum_{i=0}^{N-1} b_i e^{-j\omega i}\]
| Case | Time | Space | Notes |
|---|---|---|---|
| Best | \(O(N)\) | \(O(N)\) | Disabled (passthrough) |
| Average | \(O(N)\) | \(O(N)\) | Convolution sum |
| Worst | \(O(N)\) | \(O(N)\) | Same — no data-dependent branching |
Time is per sample. Space is for the coefficient and delay line buffers.
Given a 3-tap FIR filter with coefficients \(b = [0.25, 0.5, 0.25]\) and input sequence \(x = [1.0, 2.0, 3.0, 4.0]\):
| \(n\) | \(x[n]\) | \(x[n-1]\) | \(x[n-2]\) | \(y[n] = 0.25 x[n] + 0.5 x[n-1] + 0.25 x[n-2]\) |
|---|---|---|---|---|
| 0 | 1.0 | 0.0 | 0.0 | 0.25 |
| 1 | 2.0 | 1.0 | 0.0 | 1.0 |
| 2 | 3.0 | 2.0 | 1.0 | 2.0 |
| 3 | 4.0 | 3.0 | 2.0 | 3.0 |
windowing module are used in FIR coefficient design.An Infinite Impulse Response (IIR) filter uses feedback from its own past outputs to achieve a desired frequency response. IIR filters can match the selectivity of high-order FIR filters using far fewer coefficients, making them efficient for resource-constrained embedded systems. The trade-off is potential instability and nonlinear phase.
A causal IIR filter with \(P\) feedforward coefficients and \(Q\) feedback coefficients computes:
\[y[n] = \sum_{i=0}^{P-1} b_i \cdot x[n-i] + \sum_{i=0}^{Q-1} a_i \cdot y[n-i]\]
where: - \(b_i\) are the feedforward (numerator) coefficients - \(a_i\) are the feedback (denominator) coefficients - \(x[n-i]\) are past input samples - \(y[n-i]\) are past output samples
Sign convention: The implementation uses addition for the feedback term. This means the feedback coefficients \(a_i\) must be provided with the appropriate sign (negated relative to the standard form \(y[n] = \sum b_i x[n-i] - \sum a_i y[n-i]\)).
\[H(z) = \frac{\sum_{i=0}^{P-1} b_i z^{-i}}{1 - \sum_{i=0}^{Q-1} a_i z^{-i}}\]
The filter is stable if and only if all poles of \(H(z)\) lie strictly inside the unit circle (\(|z| < 1\)). This must be verified at design time.
| Case | Time | Space | Notes |
|---|---|---|---|
| Best | \(O(P+Q)\) | \(O(P+Q)\) | Disabled (passthrough) |
| Average | \(O(P+Q)\) | \(O(P+Q)\) | Feedforward + feedback sums |
| Worst | \(O(P+Q)\) | \(O(P+Q)\) | Same — sequential, no branching |
Time is per sample. Space stores coefficient and delay line buffers.
Given a first-order IIR (1 feedforward, 1 feedback) with \(b_0 = 0.5\), \(a_0 = 0.3\) and input \(x = [1.0, 0.0, 0.0]\):
| \(n\) | \(x[n]\) | \(y[n-1]\) | \(y[n] = 0.5 \cdot x[n] + 0.3 \cdot y[n-1]\) |
|---|---|---|---|
| 0 | 1.0 | 0.0 | 0.5 |
| 1 | 0.0 | 0.5 | 0.15 |
| 2 | 0.0 | 0.15 | 0.045 |
The output decays exponentially — characteristic of IIR filters with \(|a_0| < 1\).
FrequencyResponse class evaluates \(H(e^{j\omega})\) for both FIR and IIR
filters by accepting both \(b\) and
\(a\) coefficient arrays.Impulsive noise — EMI clicks, dropped ADC bits, salt-and-pepper sensor glitches — corrupts embedded sensor streams in a way that linear averaging amplifies rather than rejects. A single outlier sample at 100× the signal level drags a moving-average output for the entire window duration. The median filter eliminates isolated spikes completely, using only comparisons and no arithmetic on the signal values themselves, making it an ideal non-linear pre-processor for noisy ADC and sensor streams.
Let \(x[n]\) be the input sequence and \(N\) the window length. Define the sorted window snapshot as \(x_{(1)} \le x_{(2)} \le \cdots \le x_{(N)}\) (order statistics). The median filter output is:
\[y[n] = x_{(\lceil N/2 \rceil)}\]
For odd \(N\) this is the unique middle order statistic. For even \(N\) one common convention takes the average of the two central statistics, but this introduces arithmetic on the signal and the associated round-off.
A spike is suppressed if and only if it does not form a strict majority of the window, i.e., if fewer than \(\lceil N/2 \rceil\) samples share the outlier level. An isolated impulse occupies exactly one slot; it is always rejected for \(N \ge 3\).
A step transition from level \(a\) to level \(b\) causes the median to switch from \(a\) to \(b\) after exactly \(\lfloor N/2 \rfloor\) samples of delay — without overshoot, Gibbs ringing, or smearing. This is the fundamental advantage over linear filters: the nonlinearity of the sort operation preserves edges while rejecting impulses.
The causal sliding-window median introduces a latency of \(\lfloor (N-1)/2 \rfloor\) samples, which is the same group delay as a symmetric FIR of the same length. Unlike a linear FIR, however, the delay is signal-dependent near edges.
For small, fixed window sizes \(N\) (typically 3–9 in embedded use), copying the ring buffer into a scratch array and applying insertion sort is cache-friendly and avoids heap allocation. The comparison count is at most \(N(N-1)/2\) — fully bounded by the compile-time constant \(N\).
Insertion sort on the scratch copy:
\[\text{for } i = 1, \ldots, N-1: \text{ shift } \text{scratch}[0 \ldots i-1] \text{ right until sorted position for scratch}[i]\]
The median is then \(\text{scratch}[\lfloor N/2 \rfloor]\).
Huang, Yang, and Tang (1979) describe an \(O(1)\)-per-sample update based on a running histogram: the departing sample decrements its histogram bin and the arriving sample increments its bin, then the median bin is walked left or right by one position. This reduces per-sample work from \(O(N)\) to \(O(1)\) but requires a histogram array indexed over the signal alphabet — impractical for floating-point streams on embedded targets.
| Variant | Time per sample | Space | Notes |
|---|---|---|---|
| Insertion sort (used) | \(O(N)\) | \(O(N)\) | Optimal for small fixed \(N\); no heap |
| Huang histogram | \(O(1)\) | \(O(K)\) | \(K\) = alphabet size; integer signals only |
For \(N \le 9\) the insertion-sort inner loop is unrolled by the compiler and runs in a handful of compare-and-swap operations — faster in practice than histogram maintenance.
Input: \(x = [0.1, 0.1, 0.9, 0.1, 0.1]\), window size \(N = 3\), initial window pre-seeded with \(0.0\).
| \(n\) | \(x[n]\) | Window (ring buffer) | Sorted scratch | \(y[n]\) |
|---|---|---|---|---|
| 0 | 0.1 | [0.0, 0.0, 0.1] | [0.0, 0.0, 0.1] | 0.0 |
| 1 | 0.1 | [0.0, 0.1, 0.1] | [0.0, 0.1, 0.1] | 0.1 |
| 2 | 0.9 | [0.1, 0.1, 0.9] | [0.1, 0.1, 0.9] | 0.1 |
| 3 | 0.1 | [0.1, 0.9, 0.1] | [0.1, 0.1, 0.9] | 0.1 |
| 4 | 0.1 | [0.9, 0.1, 0.1] | [0.1, 0.1, 0.9] | 0.1 |
The bold value is the median at index \(\lfloor 3/2 \rfloor = 1\). The spike at \(n = 2\) (\(x = 0.9\)) appears in only one of the three window slots at any given time, so it is never the majority and never reaches the output.
MovingAverage is the linear equivalent; it smears both
noise and edges, while the median preserves edges at the cost of being
nonlinear and harder to analyse in the frequency domain.ExponentialMovingAverage provides even lower memory
cost but is sensitive to impulsive outliers.FirFilter with optimal coefficients can achieve
equiripple passband/stopband behaviour but cannot reject isolated spikes
without distorting step edges.Sensor streams in embedded systems are corrupted by zero-mean white noise. The simplest way to attenuate that noise is to average consecutive samples. The moving average does this with a sliding window of length N, treating every sample inside the window equally. Its critical property for embedded use is that the recursive running-sum form requires exactly one addition, one subtraction, and one multiply per output sample — constant work regardless of how large the window is.
Let \(x[n]\) be the input sequence. The length-\(N\) boxcar output is:
\[y[n] = \frac{1}{N} \sum_{k=0}^{N-1} x[n-k]\]
Expanding \(y[n]\) in terms of \(y[n-1]\):
\[y[n] = y[n-1] + \frac{x[n] - x[n-N]}{N}\]
Equivalently, maintain a running sum \(S[n] = \sum_{k=0}^{N-1} x[n-k]\):
\[S[n] = S[n-1] + x[n] - x[n-N], \qquad y[n] = \frac{S[n]}{N}\]
The discrete-time transfer function is:
\[H(z) = \frac{1}{N} \sum_{k=0}^{N-1} z^{-k} = \frac{1}{N} \cdot \frac{1 - z^{-N}}{1 - z^{-1}}\]
The magnitude response is a \(\mathrm{sinc}\)-shaped envelope:
\[|H(e^{j\omega})| = \frac{1}{N} \left| \frac{\sin(N\omega/2)}{\sin(\omega/2)} \right|\]
DC gain is exactly 1. Group delay is constant at \((N-1)/2\) samples — the filter is linear phase.
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(1)\) | \(O(N)\) | One add, one sub, one multiply |
The \(O(1)\) time cost is the key advantage over the naive \(O(N)\) direct-form sum.
Input: unit step \(x[n] = 1\) for \(n \ge 0\), \(N = 4\), initial state all zeros.
| \(n\) | \(x[n]\) | \(x[n-4]\) | \(S[n]\) | \(y[n]\) |
|---|---|---|---|---|
| 0 | 1 | 0 | 1 | 0.25 |
| 1 | 1 | 0 | 2 | 0.50 |
| 2 | 1 | 0 | 3 | 0.75 |
| 3 | 1 | 0 | 4 | 1.00 |
| 4 | 1 | 1 | 4 | 1.00 |
The output ramps up linearly and then holds at 1 once the window is fully filled.
ExponentialMovingAverage solves the same smoothing
problem with \(O(1)\) memory at the
cost of non-linear phase and an infinite impulse response.SavitzkyGolayFilter generalizes the boxcar by fitting a
polynomial, preserving higher-order signal features.Narrow-band interference — mains hum at 50/60 Hz and its harmonics — contaminates bio-signal acquisition (ECG, EEG, EMG) and precision instrumentation. A notch filter surgically eliminates a single frequency while leaving the rest of the spectrum intact. A comb filter extends this idea to an entire harmonic series using a single delay line, making it far more efficient than cascading individual notches when the interference is periodic.
The continuous-time transfer function for a band-reject filter with centre frequency \(f_0\) and quality factor \(Q\) is:
\[H(s) = \frac{s^2 + \omega_0^2}{s^2 + \frac{\omega_0}{Q}s + \omega_0^2}\]
Applying the bilinear transform with \(\omega_0 = 2\pi f_0 / f_s\), the discrete-time biquad coefficients (Bristow-Johnson “Cookbook” notch) are:
\[\alpha = \frac{\sin(\omega_0)}{2Q}, \quad c_\omega = \cos(\omega_0)\]
\[b_0 = b_2 = \frac{1}{1+\alpha}, \quad b_1 = \frac{-2c_\omega}{1+\alpha}\]
\[a_1 = \frac{-2c_\omega}{1+\alpha}, \quad a_2 = \frac{1-\alpha}{1+\alpha}\]
The zeros lie exactly on the unit circle at \(e^{\pm j\omega_0}\), guaranteeing infinite attenuation at \(f_0\) in exact arithmetic.
Transposed Direct Form II (TDF-II) realises this with two state variables \(z_1, z_2\):
\[y[n] = b_0 x[n] + z_1[n-1]\] \[z_1[n] = b_1 x[n] - a_1 y[n] + z_2[n-1]\] \[z_2[n] = b_2 x[n] - a_2 y[n]\]
A comb filter exploits the identity: subtracting (or adding) a signal delayed by \(D\) samples creates periodic nulls (or peaks) spaced every \(f_s / D\) Hz:
Feedforward (FIR) comb:
\[y[n] = x[n] - g \cdot x[n-D]\]
Zeros at \(\omega = 2\pi k / D\) for \(k = 0, 1, \ldots, D-1\). The system is always stable.
Feedback (IIR) comb:
\[y[n] = x[n] + g \cdot y[n-D]\]
Poles at the same locations. The system is stable if and only if \(|g| < 1\).
| Case | Time | Space | Notes |
|---|---|---|---|
| Notch | \(O(1)\) | \(O(1)\) | Fixed biquad; 2 multiplies/adds |
| Comb | \(O(1)\) | \(O(D)\) | Single circular-buffer read/write |
Notch example — \(f_0 = 50\) Hz, \(f_s = 1000\) Hz, \(Q = 10\):
Comb example — \(D = 4\), \(g = 1\) (FIR):
Impulse response: \([1, 0, 0, 0, -1, 0, 0, 0, 0, \ldots]\) — zeros at 0, fs/4, fs/2, 3fs/4.
Many sensor signals — ECG, PPG, spectroscopy, vibration — carry narrow peaks whose height, width, and area carry physical meaning. A boxcar moving average suppresses noise by treating the signal as locally constant, but this assumption flattens and widens every peak. The Savitzky-Golay filter replaces that assumption with a stronger one: the signal is locally a low-degree polynomial. Fitting that polynomial to each window by least squares and evaluating it at the window centre preserves peak shape far better while still attenuating high-frequency noise.
Let \(x[n]\) be the input sequence. Define a symmetric window of length \(W\) (odd) centred at sample \(n\), with half-width \(h = (W-1)/2\). The window offsets are \(j = -h, -h+1, \ldots, h\).
Fit a polynomial \(p(j) = \sum_{d=0}^{P} a_d \, j^d\) of degree \(P < W\) to the window samples \(\{x[n+j]\}\) in the least-squares sense. The output for the \(D\)-th derivative at the centre is:
\[y[n] = D! \, a_D\]
Assemble the Vandermonde matrix \(A \in \mathbb{R}^{W \times (P+1)}\) with \(A_{k,d} = j_k^{\,d}\), \(j_k = k - h\). The least-squares coefficients solve:
\[(A^\top A)\,\mathbf{a} = A^\top \mathbf{x}\]
The projection matrix \(C = (A^\top A)^{-1} A^\top\) is \((P+1) \times W\). Row \(D\) of \(C\), multiplied by \(D!\), gives the FIR convolution kernel \(\mathbf{c} \in \mathbb{R}^W\):
\[y[n] = \sum_{k=0}^{W-1} c_k \, x[n - h + k]\]
Because \(A\) depends only on \(W\) and \(P\), the kernel \(\mathbf{c}\) is fixed and computed once.
The centre row (\(D = 0\)) reproduces a polynomial fit evaluated at \(j = 0\). For the standard 5-point, quadratic (\(P = 2\)) case the kernel is:
\[\mathbf{c} = \frac{1}{35}[-3, 12, 17, 12, -3]\]
DC gain is exactly 1; the kernel is symmetric, so the filter has linear phase with group delay \(h = (W-1)/2\) samples.
Row \(D\) of \(C\) times \(D!\) yields the \(D\)-th smoothed derivative kernel. For \(D = 1\) the output approximates \(\mathrm{d}x/\mathrm{d}n\) (in samples); multiply by \(1/T_s^D\) to convert to physical units.
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(W)\) | \(O(W)\) | One multiply-accumulate per tap per sample |
| Init | \(O(P^3)\) | \(O(P^2 W)\) | Constexpr kernel precomputed at compile time |
The runtime cost is \(W\) multiply-adds — identical to a length-\(W\) FIR filter. The \(O(P^3)\) Gaussian elimination occurs entirely at compile time with zero runtime overhead.
Window \(W = 5\), polynomial order \(P = 2\), derivative \(D = 0\), half-width \(h = 2\):
Input \([0, 0, 0, 0, 1]\): output = \(-3/35 \approx -0.086\) (transient). After the delay line fills with a constant signal \(v\), output = \(v\) exactly.
MovingAverage is the \(P =
0\) special case of Savitzky-Golay (constant polynomial); it
maximises noise rejection but maximally distorts peaks.Fir with custom tap weights generalises both;
Savitzky-Golay computes those weights analytically from least-squares
polynomial fitting.PolynomialFitting (offline) performs the same
projection without the sliding-window structure.MedianFilter suppresses impulsive spikes rather than
Gaussian noise; the two are complementary.Forward Kinematics computes the 3D Cartesian positions of all joints in a serial kinematic chain, given the joint angles. For a chain of \(n\) revolute joints, it produces \(n + 1\) position vectors (base through end-effector) in \(O(n)\) time.
This is the geometric foundation for visualization, collision detection, and workspace analysis. It answers the question: “given these joint angles, where is each joint in world space?”
Starting from the base at the origin, the position of joint \(i + 1\) is:
\[p_{i+1} = p_i + R_{\text{world} \to i} \cdot r_{i \to i+1}\]
where:
Each rotation \(R_k(q_k)\) is computed via Rodrigues’ formula from the joint axis and angle.
For the last link, the offset to the end-effector is approximated as \(2 \cdot r_{j \to \text{CoM}}\) (assuming the center of mass is at the midpoint of the link).
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(n)\) | \(O(n)\) | Single pass over chain |
Consider a 2-link planar arm with link lengths \(L_1 = 1\), \(L_2 = 0.8\), Y-axis joints, and \(q = [\pi/4, -\pi/6]\).
parentToJoint
and jointToCoM are both zero, consecutive joints collapse
to the same position.jointToCoM * 2, which is exact only for
uniform-density links with the CoM at the geometric center.math::RotationAboutAxis from Geometry3D.Inverse Kinematics (IK) solves the problem dual to Forward Kinematics: given a desired 3D end-effector position \(p_{\text{target}}\), find joint angles \(q\) such that the end-effector reaches that position. This is the core computational problem in robot arm control, motion planning, and teleoperation.
The Damped Least Squares (DLS) method, also known as the Levenberg-Marquardt approach, iteratively refines a joint-angle estimate by solving a small linear system at each step. Unlike the plain pseudoinverse, DLS adds a damping term \(\lambda^2 I\) that keeps the system numerically stable even when the manipulator is near a singular configuration (such as a fully extended arm).
For a serial revolute-joint chain with \(n\) joints, the geometric Jacobian \(J \in \mathbb{R}^{3 \times n}\) maps joint velocities \(\dot{q}\) to end-effector linear velocity:
\[\dot{p}_{\text{ee}} = J(q) \, \dot{q}\]
Column \(i\) of \(J\) is the linear velocity contribution of joint \(i\):
\[J_i = a_i^w \times (p_{\text{ee}} - p_i)\]
where: - \(a_i^w = R_{0 \to i} \, a_i^{\text{link}}\) is the joint axis in world frame (accumulated rotation applied to the link-frame axis) - \(p_i\) is the world-frame position of joint \(i\) (from Forward Kinematics) - \(p_{\text{ee}}\) is the current end-effector position
Define the position error:
\[e = p_{\text{target}} - p_{\text{ee}}(q) \in \mathbb{R}^3\]
The DLS joint update per iteration is:
\[\Delta q = J^\top \bigl(J J^\top + \lambda^2 I\bigr)^{-1} e\]
This is equivalent to solving the \(3 \times 3\) linear system:
\[\underbrace{\bigl(J J^\top + \lambda^2 I\bigr)}_{A} \, y = e, \quad \Delta q = J^\top y\]
where \(A \in \mathbb{R}^{3 \times
3}\) is always symmetric positive definite (the damping \(\lambda^2 I\) guarantees this), so
GaussianElimination<T, 3> solves it reliably.
The updated joint angles are:
\[q_{k+1} = q_k + \Delta q\]
The loop terminates when: - \(\|e\| < \epsilon_{\text{tol}}\) (success), or - The maximum iteration count is reached (failure to converge — target unreachable or tolerance too tight)
At a kinematic singularity (e.g., fully extended arm), \(J J^\top\) becomes rank-deficient. The pseudoinverse \(J^+ = J^\top (J J^\top)^{-1}\) would produce infinite joint velocities. The damping term \(\lambda^2 I\) bounds the solution:
\[\|\Delta q\| \leq \frac{\|e\|}{\lambda}\]
The cost is a small positional bias proportional to \(\lambda\): the algorithm converges to within approximately \(\lambda^2 \|e\|\) rather than exact zero. Choosing \(\lambda \in [0.01, 0.2]\) balances stability and accuracy for typical robot geometries.
| Case | Time | Space | Notes |
|---|---|---|---|
| Per iteration | \(O(n)\) | \(O(n)\) | FK sweep + Jacobian + \(3\times3\) solve |
| Total | \(O(k \cdot n)\) | \(O(n)\) | \(k\) = iterations (typically 10–50) |
The \(3 \times 3\) linear solve is \(O(1)\) (constant-size), independent of the number of joints \(n\).
Consider a 2-link planar arm with \(l_1 = 1.0\), \(l_2 = 0.8\), both joints rotating about the Z-axis, target \(p_{\text{target}} = (0.5, 1.0, 0)\), initial guess \(q = (0, 0)\), \(\lambda = 0.1\).
Iteration 1:
After ~40 iterations \(q\) converges to joints that produce \(p_{\text{ee}} \approx (0.5, 1.0, 0)\).
converged = false; the end-effector
will be as close as geometrically possible.std::cos,
std::sin, and std::sqrt; Q15/Q31 fixed-point
types are not supported.dampingFactor and tolerance must both be
strictly positive. The damping term \(\lambda^2 I\) ensures \(A\) is symmetric positive definite only
when \(\lambda > 0\); both values
are asserted in debug builds via really_assert.numerical/math/Geometry3D.hpp: Provides
CrossProduct for Jacobian column computation,
RotationAboutAxis for accumulated rotation, and
VectorNorm for convergence check.A neural network is a parameterized function \(f_\theta: \mathbb{R}^n \to \mathbb{R}^m\) built by composing simple, differentiable transformations called layers. Each layer applies an affine map followed by a non-linear activation, and the whole composition is trained end-to-end by gradient-based optimization.
Neural networks are powerful because of the universal approximation theorem: a single hidden layer with enough neurons can approximate any continuous function on a compact set to arbitrary accuracy. In practice, depth (many layers) is more parameter-efficient than width for learning hierarchical features.
This library provides a minimal, statically-sized neural network framework designed for embedded inference and on-device training — no heap allocation, no dynamic shapes, full compile-time dimension checking.
Given \(L\) layers, the network computes:
\[a_0 = x\] \[z_\ell = W_\ell \, a_{\ell-1} + b_\ell, \quad \ell = 1, \ldots, L\] \[a_\ell = f_\ell(z_\ell)\] \[\hat{y} = a_L\]
where \(W_\ell \in \mathbb{R}^{n_\ell \times n_{\ell-1}}\) are weights, \(b_\ell \in \mathbb{R}^{n_\ell}\) are biases, and \(f_\ell\) is the activation function for layer \(\ell\).
Training minimizes a scalar loss \(\mathcal{L}(\hat{y}, y)\) that measures how far the prediction \(\hat{y}\) is from the target \(y\). Common choices:
| Loss | Formula | Use Case |
|---|---|---|
| MSE | \(\frac{1}{m}\sum(\hat{y}_i - y_i)^2\) | Regression |
| BCE | \(-\sum[y_i \log \hat{y}_i + (1-y_i)\log(1-\hat{y}_i)]\) | Binary classification |
| CCE | \(-\sum y_i \log \hat{y}_i\) | Multi-class classification |
Backpropagation efficiently computes \(\nabla_\theta \mathcal{L}\) via the chain rule, working from the output layer backward:
\[\delta_L = \nabla_{a_L}\mathcal{L} \odot f_L'(z_L)\] \[\delta_\ell = (W_{\ell+1}^T \delta_{\ell+1}) \odot f_\ell'(z_\ell)\]
The gradients with respect to parameters are:
\[\frac{\partial \mathcal{L}}{\partial W_\ell} = \delta_\ell \, a_{\ell-1}^T, \qquad \frac{\partial \mathcal{L}}{\partial b_\ell} = \delta_\ell\]
An optimizer uses the gradients to update the parameter vector \(\theta\):
\[\theta_{t+1} = \theta_t - \eta \, \nabla_\theta \mathcal{L}\]
where \(\eta\) is the learning rate. More sophisticated optimizers (momentum, Adam) modify this basic rule.
| Phase | Time | Space |
|---|---|---|
| Forward pass | \(O\!\left(\sum_{\ell=1}^L n_\ell \cdot n_{\ell-1}\right)\) | \(O\!\left(\sum n_\ell\right)\) activations |
| Backward pass | Same as forward | Same + gradient storage |
| Parameter update | \(O(P)\) | \(O(P)\) optimizer state |
where \(P = \sum_\ell (n_\ell \cdot n_{\ell-1} + n_\ell)\) is the total parameter count. For small embedded networks (\(P < 10{,}000\)), both passes complete in microseconds.
Network: 2 inputs → 2 hidden (ReLU) → 1 output (Sigmoid). Learning XOR.
Architecture:
graph LR
x1((x₁)) --> h1((h₁))
x1 --> h2((h₂))
x2((x₂)) --> h1
x2 --> h2
h1 --> y((ŷ))
h2 --> y
Epoch 0 — Forward pass with input \(x = [1, 0]^T\), target \(y = 1\):
| Step | Computation | Result |
|---|---|---|
| Hidden pre-activation | \(z_1 = W_1 x + b_1\) | \([0.3, -0.1]^T\) |
| Hidden activation | \(a_1 = \text{ReLU}(z_1)\) | \([0.3, 0.0]^T\) |
| Output pre-activation | \(z_2 = W_2 a_1 + b_2\) | \([0.15]\) |
| Output activation | \(\hat{y} = \sigma(z_2)\) | \([0.537]\) |
| Loss | \(\mathcal{L} = -(y\log\hat{y} + (1-y)\log(1-\hat{y}))\) | \(0.621\) |
Epoch 0 — Backward pass:
| Step | Computation | Result |
|---|---|---|
| Output gradient | \(\delta_2 = \hat{y} - y\) | \([-0.463]\) |
| \(\nabla W_2\) | \(\delta_2 \cdot a_1^T\) | \([-0.139, 0]\) |
| Hidden gradient | \(\delta_1 = W_2^T \delta_2 \odot \text{ReLU}'(z_1)\) | \([-0.231, 0]^T\) |
| \(\nabla W_1\) | \(\delta_1 \cdot x^T\) | \([[-0.231, 0], [0, 0]]\) |
Update: \(W \leftarrow W - 0.1 \cdot \nabla W\). After ~500 epochs, the network correctly classifies all four XOR inputs.
| Variant | Key Difference |
|---|---|
| Convolutional Neural Network (CNN) | Layers share weights spatially; efficient for image/signal data |
| Recurrent Neural Network (RNN) | Layers share weights across time steps; models sequences |
| Residual Network (ResNet) | Skip connections mitigate vanishing gradients in very deep networks |
| Transformer | Attention-based; no recurrence; state-of-the-art for sequences |
| Quantized Neural Network | Weights and activations in low-bit integers; optimal for MCU deployment |
graph TD
NN["Neural Network"]
Layer["Dense Layer"]
Act["Activation Functions"]
Loss["Loss Functions"]
Opt["Optimizer"]
Reg["Regularization"]
Model["Model"]
LR["Linear Regression"]
Layer --> NN
Act --> NN
Loss --> NN
Opt --> NN
Reg --> NN
NN --> Model
NN -.->|"single-layer, linear activation, MSE loss"| LR
| Component | Relationship |
|---|---|
| Dense Layer | The fundamental building block; computes affine transformations |
| Activation Functions | Introduce non-linearity after each layer |
| Loss Functions | Define the training objective |
| Optimizer | Drives parameter updates via gradient descent |
| Regularization | Penalizes complexity to prevent overfitting |
| Model | Composes layers into a trainable pipeline |
| Linear Regression | Special case: single layer, identity activation, MSE loss |
A dense (fully-connected) layer is the most fundamental building block of a neural network. It maps an input vector \(a_{\text{in}} \in \mathbb{R}^n\) to an output vector \(a_{\text{out}} \in \mathbb{R}^m\) through a learnable affine transformation followed by a non-linear activation:
\[a_{\text{out}} = f(W \, a_{\text{in}} + b)\]
Every input neuron is connected to every output neuron — hence “fully connected.” The layer’s parameters are the weight matrix \(W\) and bias vector \(b\); training adjusts these to minimize the loss.
In this library, input size, output size, and parameter count are all compile-time constants, enabling stack allocation and dimension checking with zero runtime overhead.
\[z = W \, a_{\text{in}} + b, \qquad a_{\text{out}} = f(z)\]
where \(W \in \mathbb{R}^{m \times n}\), \(b \in \mathbb{R}^m\), and \(f\) is the activation function.
Given the gradient of the loss with respect to the output \(\frac{\partial \mathcal{L}}{\partial a_{\text{out}}}\):
Pre-activation gradient: \[\delta = \frac{\partial \mathcal{L}}{\partial a_{\text{out}}} \odot f'(z)\]
Weight gradient: \[\frac{\partial \mathcal{L}}{\partial W} = \delta \, a_{\text{in}}^T\]
Bias gradient: \[\frac{\partial \mathcal{L}}{\partial b} = \delta\]
Input gradient (propagated to the previous layer): \[\frac{\partial \mathcal{L}}{\partial a_{\text{in}}} = W^T \delta\]
\[P = m \times n + m = m(n + 1)\]
For a layer with 128 inputs and 64 outputs: \(P = 64 \times 129 = 8{,}256\) parameters.
| Operation | Time | Space |
|---|---|---|
| Forward (\(W a + b\)) | \(O(m \cdot n)\) | \(O(m)\) output + \(O(m)\) cached \(z\) |
| Backward (\(\delta\), \(\nabla W\), \(\nabla b\)) | \(O(m \cdot n)\) | \(O(m \cdot n)\) weight gradient |
| Total parameters | — | \(O(m \cdot n + m)\) |
The matrix-vector product dominates both passes. For embedded networks (e.g. \(n = 32, m = 16\)), a single forward pass takes ~512 multiply-accumulate operations.
Layer: 3 inputs → 2 outputs, ReLU activation.
\[W = \begin{bmatrix} 0.5 & -0.3 & 0.8 \\ 0.1 & 0.7 & -0.2 \end{bmatrix}, \quad b = \begin{bmatrix} 0.1 \\ -0.1 \end{bmatrix}, \quad a_{\text{in}} = \begin{bmatrix} 1.0 \\ 0.5 \\ -1.0 \end{bmatrix}\]
Forward:
| Step | Computation | Result |
|---|---|---|
| \(z = W a_{\text{in}} + b\) | \([0.5 - 0.15 - 0.8 + 0.1,\; 0.1 + 0.35 + 0.2 - 0.1]\) | \([-0.35,\; 0.55]^T\) |
| \(a_{\text{out}} = \text{ReLU}(z)\) | \([\max(0, -0.35),\; \max(0, 0.55)]\) | \([0.0,\; 0.55]^T\) |
Backward with \(\frac{\partial \mathcal{L}}{\partial a_{\text{out}}} = [0.2,\; -0.4]^T\):
| Step | Computation | Result |
|---|---|---|
| \(\delta = \nabla a_{\text{out}} \odot \text{ReLU}'(z)\) | \([0.2 \cdot 0,\; -0.4 \cdot 1]\) | \([0,\; -0.4]^T\) |
| \(\nabla W = \delta \, a_{\text{in}}^T\) | row 1: all zeros; row 2: \(-0.4 \times [1, 0.5, -1]\) | \(\begin{bmatrix}0 & 0 & 0\\-0.4 & -0.2 & 0.4\end{bmatrix}\) |
| \(\nabla b = \delta\) | — | \([0,\; -0.4]^T\) |
| \(\nabla a_{\text{in}} = W^T \delta\) | \(W^T [0, -0.4]^T\) | \([-0.04,\; -0.28,\; 0.08]^T\) |
float consumes 256 KB. Size layers to fit the target’s
stack budget.| Variant | Key Difference |
|---|---|
| Convolutional layer | Weight sharing across spatial positions; \(O(k^2 \cdot c)\) parameters per filter instead of \(O(n \cdot m)\) |
| Recurrent layer | Shares weights across time steps; adds a hidden state feedback connection |
| Batch normalization layer | Normalizes activations to zero mean and unit variance; accelerates training |
| Dropout layer | Randomly zeros activations during training; regularization effect |
| Sparse layer | Only a subset of connections exist; reduces parameter count and computation |
graph TD
Layer["Dense Layer"]
Act["Activation Functions"]
Model["Model"]
Opt["Optimizer"]
LR["Linear Regression"]
Act --> Layer
Layer --> Model
Model --> Opt
Layer -.->|"no activation, MSE loss"| LR
| Component | Relationship |
|---|---|
| Activation Functions | Applied element-wise after the affine transformation |
| Model | Chains multiple dense layers into a network |
| Optimizer | Updates \(W\) and \(b\) using the computed gradients |
| Linear Regression | A dense layer with identity activation and MSE loss is equivalent to linear regression |
An activation function \(f\) is a non-linear, element-wise transformation applied after the affine map in each neural network layer:
\[a = f(z) = f(W x + b)\]
Without activation functions, stacking layers would collapse into a single affine transformation — the network could only represent linear mappings regardless of depth. Activation functions are what give neural networks their expressive power.
The choice of activation function controls gradient flow during back-propagation, output range, and computational cost — all critical on resource-constrained embedded targets.
Every activation function exposes two operations:
| Operation | Definition | Purpose |
|---|---|---|
| Forward | \(a = f(z)\) | Transform the pre-activation |
| Backward | \(f'(z)\) | Provide the local derivative for back-propagation |
The chain rule connects them during training:
\[\frac{\partial \mathcal{L}}{\partial z} = \frac{\partial \mathcal{L}}{\partial a} \cdot f'(z)\]
\[f(z) = \max(0, z), \qquad f'(z) = \begin{cases} 1 & z > 0 \\ 0 & z \le 0 \end{cases}\]
\[f(z) = \begin{cases} z & z > 0 \\ \alpha z & z \le 0 \end{cases}, \qquad f'(z) = \begin{cases} 1 & z > 0 \\ \alpha & z \le 0 \end{cases}\]
where \(\alpha\) is a small positive constant (typically \(0.01\)). Prevents dead neurons by allowing a small gradient for \(z < 0\).
\[f(z) = \frac{1}{1 + e^{-z}}, \qquad f'(z) = f(z)(1 - f(z))\]
\[f(z) = \tanh(z) = \frac{e^z - e^{-z}}{e^z + e^{-z}}, \qquad f'(z) = 1 - f(z)^2\]
For a vector \(\mathbf{z} \in \mathbb{R}^k\):
\[f(z_i) = \frac{e^{z_i}}{\sum_{j=1}^{k} e^{z_j}}\]
| Activation | Forward (per element) | Backward (per element) | Notes |
|---|---|---|---|
| ReLU | \(O(1)\) — comparison | \(O(1)\) — comparison | Fastest |
| Leaky ReLU | \(O(1)\) — comparison + multiply | \(O(1)\) | Negligible overhead vs ReLU |
| Sigmoid | \(O(1)\) — exp + divide | \(O(1)\) — reuse forward result | Requires exp() |
| Tanh | \(O(1)\) — exp (twice) | \(O(1)\) — reuse forward result | Requires exp() |
| Softmax | \(O(k)\) — vector exp + sum | \(O(k^2)\) — full Jacobian | Significantly more expensive |
Scenario: Forward and backward pass through a 3-neuron hidden layer with ReLU, given pre-activations \(z = [-0.5, \; 1.2, \; 0.0]\).
Forward:
| Neuron | \(z\) | \(\text{ReLU}(z)\) |
|---|---|---|
| 1 | \(-0.5\) | \(0.0\) |
| 2 | \(1.2\) | \(1.2\) |
| 3 | \(0.0\) | \(0.0\) |
Backward with incoming gradient \(\frac{\partial \mathcal{L}}{\partial a} = [0.3, \; -0.7, \; 0.1]\):
| Neuron | \(f'(z)\) | \(\frac{\partial \mathcal{L}}{\partial z}\) |
|---|---|---|
| 1 | \(0\) (dead) | \(0.3 \times 0 = 0\) |
| 2 | \(1\) | \(-0.7 \times 1 = -0.7\) |
| 3 | \(0\) (at boundary) | \(0.1 \times 0 = 0\) |
Neuron 1 is dead — its gradient is zero and its weights will not update. If this persists across all training samples, the neuron is permanently inactive.
| Variant | Key Difference |
|---|---|
| PReLU (Parametric ReLU) | \(\alpha\) is a learnable parameter per channel |
| ELU (Exponential LU) | \(\alpha(e^z - 1)\) for \(z < 0\); smooth and zero-centered |
| GELU (Gaussian Error LU) | \(z \cdot \Phi(z)\); used in Transformers |
| Swish / SiLU | \(z \cdot \sigma(z)\); smooth, non-monotonic |
| Hard Sigmoid / Hard Tanh | Piece-wise linear approximations; no transcendentals |
exp() calls on MCUs without FPU.graph TD
Act["Activation Functions"]
Layer["Dense Layer"]
NN["Neural Network"]
Loss["Loss Functions"]
Act --> Layer
Layer --> NN
NN --> Loss
| Component | Relationship |
|---|---|
| Dense Layer | Applies the activation function after the affine transformation |
| Neural Network | Activations enable the non-linear function approximation that makes deep networks useful |
| Loss Functions | The output activation must match the loss: Sigmoid + BCE, Softmax + CCE |
A loss function \(\mathcal{L}(\hat{y}, y)\) quantifies how far a model’s prediction \(\hat{y}\) is from the true target \(y\). Training a neural network means finding parameters \(\theta\) that minimize the expected loss over the training data:
\[\theta^* = \arg\min_\theta \; \mathbb{E}[\mathcal{L}(f_\theta(x), y)]\]
The loss function defines the entire learning objective — different losses lead to different optimal models even on the same data. It must also provide a gradient \(\nabla_{\hat{y}} \mathcal{L}\) for back-propagation.
\[\mathcal{L}_{\text{MSE}} = \frac{1}{m} \sum_{i=1}^{m} (\hat{y}_i - y_i)^2\]
\[\frac{\partial \mathcal{L}}{\partial \hat{y}_i} = \frac{2}{m}(\hat{y}_i - y_i)\]
\[\mathcal{L}_{\text{MAE}} = \frac{1}{m} \sum_{i=1}^{m} |\hat{y}_i - y_i|\]
\[\frac{\partial \mathcal{L}}{\partial \hat{y}_i} = \frac{1}{m} \mathrm{sign}(\hat{y}_i - y_i)\]
\[\mathcal{L}_{\text{BCE}} = -\frac{1}{m}\sum_{i=1}^{m} \left[ y_i \log \hat{y}_i + (1 - y_i) \log(1 - \hat{y}_i) \right]\]
\[\frac{\partial \mathcal{L}}{\partial \hat{y}_i} = -\frac{1}{m}\left(\frac{y_i}{\hat{y}_i} - \frac{1 - y_i}{1 - \hat{y}_i}\right)\]
\[\mathcal{L}_{\text{CCE}} = -\sum_{i=1}^{k} y_i \log \hat{y}_i\]
\[\frac{\partial \mathcal{L}}{\partial \hat{y}_i} = -\frac{y_i}{\hat{y}_i}\]
| Loss | Forward | Backward | Notes |
|---|---|---|---|
| MSE | \(O(m)\) | \(O(m)\) | Cheapest; no transcendentals |
| MAE | \(O(m)\) | \(O(m)\) | Requires sign() |
| BCE | \(O(m)\) | \(O(m)\) | Requires log() |
| CCE | \(O(k)\) | \(O(k)\) | Requires log() |
All losses are \(O(m)\) where \(m\) is the output dimension. The computational cost is negligible compared to the dense layer matrix products.
Scenario: 3-class classification. Target \(y = [0, 1, 0]\) (class 2). Softmax output \(\hat{y} = [0.1, 0.7, 0.2]\).
CCE Forward:
\[\mathcal{L} = -(0 \cdot \log 0.1 + 1 \cdot \log 0.7 + 0 \cdot \log 0.2) = -\log(0.7) \approx 0.357\]
CCE Backward:
| \(i\) | \(y_i\) | \(\hat{y}_i\) | \(\partial \mathcal{L}/\partial \hat{y}_i = -y_i / \hat{y}_i\) |
|---|---|---|---|
| 1 | 0 | 0.1 | \(0\) |
| 2 | 1 | 0.7 | \(-1.429\) |
| 3 | 0 | 0.2 | \(0\) |
The gradient is non-zero only for the true class, and its magnitude \(1/\hat{y}_2\) grows as the prediction worsens — providing a strong corrective signal.
For comparison — MSE on the same example:
\[\mathcal{L}_{\text{MSE}} = \frac{1}{3}[(0.1)^2 + (0.7-1)^2 + (0.2)^2] = \frac{1}{3}[0.01 + 0.09 + 0.04] = 0.047\]
MSE gives a much weaker signal and does not account for the probabilistic nature of the output.
log() function
needed for cross-entropy is expensive and ill-conditioned near zero in
fixed-point. Evaluate in floating-point.| Variant | Key Difference |
|---|---|
| Huber loss | Quadratic for small errors, linear for large; robust regression |
| Focal loss | Down-weights well-classified examples; addresses class imbalance |
| KL divergence | Measures distance between two distributions; used in variational inference |
| Hinge loss | Margin-based; used in SVMs and some neural classifiers |
| Contrastive loss | Learns similarity metrics; used in Siamese networks |
graph TD
Loss["Loss Functions"]
Act["Activation Functions"]
Opt["Optimizer"]
Reg["Regularization"]
Model["Model"]
LR["Linear Regression"]
Act -->|"output activation must match loss"| Loss
Loss --> Opt
Reg -->|"added to loss"| Loss
Loss --> Model
Loss -.->|"MSE + normal equation"| LR
| Component | Relationship |
|---|---|
| Activation Functions | Output activation must match: Sigmoid ↔︎ BCE, Softmax ↔︎ CCE, identity ↔︎ MSE |
| Optimizer | Uses \(\nabla \mathcal{L}\) to update parameters |
| Regularization | Adds a penalty term to the loss: \(\mathcal{L}_{\text{total}} = \mathcal{L} + \lambda \Omega(\theta)\) |
| Linear Regression | Solved analytically when the loss is MSE and the model is linear |
A Model composes a sequence of dense layers into a single trainable function \(f: \mathbb{R}^n \to \mathbb{R}^m\). It is the orchestrator that:
In this library, the Model is fully statically typed — layer dimensions, parameter counts, and memory footprints are all known at compile time, enabling zero-overhead abstraction on embedded targets.
For \(L\) layers with transformations \(f_1, f_2, \ldots, f_L\):
\[\hat{y} = (f_L \circ f_{L-1} \circ \cdots \circ f_1)(x) = f_L(f_{L-1}(\ldots f_1(x) \ldots))\]
Each \(f_\ell\) is a dense layer: \(f_\ell(a) = \sigma_\ell(W_\ell a + b_\ell)\).
All weights and biases are concatenated into a single vector:
\[\theta = [\text{vec}(W_1), b_1, \text{vec}(W_2), b_2, \ldots, \text{vec}(W_L), b_L] \in \mathbb{R}^P\]
where \(P = \sum_{\ell=1}^L m_\ell(n_\ell + 1)\).
graph LR
X["x ∈ ℝⁿ"] --> L1["Layer 1"] --> L2["Layer 2"] --> Ldots["⋯"] --> LL["Layer L"] --> Y["ŷ ∈ ℝᵐ"]
\[\frac{\partial \mathcal{L}}{\partial \theta_\ell} = \frac{\partial \mathcal{L}}{\partial a_L} \cdot \frac{\partial a_L}{\partial a_{L-1}} \cdots \frac{\partial a_{\ell+1}}{\partial a_\ell} \cdot \frac{\partial a_\ell}{\partial \theta_\ell}\]
Each layer stores its input \(a_{\ell-1}\) during the forward pass so it can compute \(\nabla W_\ell\) and \(\nabla b_\ell\) during the backward pass.
graph TD
FP["Forward pass: ŷ = Model(x)"]
LC["Loss: ℒ(ŷ, y)"]
BP["Backward pass: ∇θ ℒ"]
UP["Update: θ ← θ − η ∇θ ℒ"]
FP --> LC --> BP --> UP --> FP
| Operation | Time | Space |
|---|---|---|
| Forward pass | \(O(P)\) | \(O(\sum n_\ell)\) cached activations |
| Backward pass | \(O(P)\) | \(O(P)\) gradients |
GetParameters() |
\(O(P)\) | \(O(P)\) flat vector |
SetParameters() |
\(O(P)\) | — |
All operations scale linearly with the total parameter count \(P\).
Model: 2 → 3 → 1 (two layers).
Compile-time verification chain:
| Check | Condition | Status |
|---|---|---|
| Layer 1 input size = Model input size | \(2 = 2\) | ✓ |
| Layer 1 output size = Layer 2 input size | \(3 = 3\) | ✓ |
| Layer 2 output size = Model output size | \(1 = 1\) | ✓ |
All types derive from Layer |
type trait check | ✓ |
Parameter layout (\(P = 3(2 + 1) + 1(3 + 1) = 9 + 4 = 13\)):
| Index | Parameter |
|---|---|
| 0–5 | \(W_1\) (3×2 = 6 elements) |
| 6–8 | \(b_1\) (3 elements) |
| 9–11 | \(W_2\) (1×3 = 3 elements) |
| 12 | \(b_2\) (1 element) |
Forward pass with \(x = [1.0, 0.5]^T\):
Backward pass with loss gradient \(\delta_{\text{out}} = [0.12]\):
Optimizer receives the full \(\nabla \theta \in \mathbb{R}^{13}\) and updates \(\theta\).
static_assert
fires during compilation.static_assert(sizeof...(Layers) > 0).GetParameters()
and SetParameters() must use the same concatenation order.
The implementation iterates layers via std::index_sequence
to guarantee consistency.Model::TotalParameters. A mismatch is also caught at
compile time.| Variant | Key Difference |
|---|---|
| Sequential model (dynamic) | Layers stored in a container; dimension checked at runtime instead of compile time |
| Functional API | Supports branching and merging (DAG topology instead of linear chain) |
| Residual model | Adds skip connections: \(a_{\ell+2} = f_{\ell+1}(a_\ell) + a_\ell\) |
| Recurrent model | Unrolls the same layer across time steps |
SetParameters() and only
Forward() is called at runtime.graph TD
Model["Model"]
Layer["Dense Layer"]
Loss["Loss Functions"]
Opt["Optimizer"]
Reg["Regularization"]
NN["Neural Network"]
Layer --> Model
Model --> Opt
Model --> Loss
Reg --> Loss
Model --> NN
| Component | Relationship |
|---|---|
| Dense Layer | The Model is a sequence of layers stored in a
std::tuple |
| Loss Functions | Measures prediction error; the Model delegates loss computation to a Loss object |
| Optimizer | Receives the flat parameter/gradient vectors from the Model and returns updated parameters |
| Regularization | Added to the loss before optimization |
Bayesian Optimization (BO) is a gradient-free, sample-efficient strategy for optimizing black-box functions that are expensive to evaluate. Unlike gradient-based methods (gradient descent, Adam), BO does not require access to derivatives. It is ideal for calibrating numerical controllers, tuning hyperparameters, or any optimization task where each function evaluation is costly — for example, running a full closed-loop simulation.
BO maintains a probabilistic surrogate model (typically a Gaussian Process) of the objective function. At each iteration, it uses an acquisition function to select the next query point that balances exploration (uncertain regions) with exploitation (promising regions). After evaluating the true objective at that point, the surrogate is updated and the process repeats.
A Gaussian Process (GP) defines a distribution over functions:
\[f \sim \mathcal{GP}(m(\mathbf{x}), k(\mathbf{x}, \mathbf{x}'))\]
With zero mean \(m = 0\) and squared-exponential (RBF) kernel:
\[k(\mathbf{x}, \mathbf{x}') = \sigma_f^2 \exp\!\left(-\frac{\|\mathbf{x} - \mathbf{x}'\|^2}{2\ell^2}\right)\]
where \(\ell\) is the length-scale and \(\sigma_f^2\) is the signal variance.
Given \(n\) observations \(\{(\mathbf{x}_i, y_i)\}\), the GP posterior at a new point \(\mathbf{x}_*\) is Gaussian:
\[p(f_* | \mathbf{X}, \mathbf{y}, \mathbf{x}_*) = \mathcal{N}(\mu_*, \sigma_*^2)\]
\[\mu_* = \mathbf{k}_*^T (\mathbf{K} + \sigma_n^2 \mathbf{I})^{-1} \mathbf{y}\]
\[\sigma_*^2 = k_{**} - \mathbf{k}_*^T (\mathbf{K} + \sigma_n^2 \mathbf{I})^{-1} \mathbf{k}_*\]
where: - \(\mathbf{K}_{ij} = k(\mathbf{x}_i, \mathbf{x}_j)\) is the \(n \times n\) kernel matrix - \(\mathbf{k}_{*,i} = k(\mathbf{x}_*, \mathbf{x}_i)\) is the cross-kernel vector - \(\sigma_n^2\) is the observation noise variance
For minimization, the Expected Improvement (EI) at \(\mathbf{x}\) given best observed value \(f^* = \min_i y_i\) is:
\[\text{EI}(\mathbf{x}) = \mathbb{E}[\max(f^* - f(\mathbf{x}), 0)]\]
For a GP surrogate this evaluates analytically:
\[\text{EI}(\mathbf{x}) = (f^* - \mu_*)\,\Phi(z) + \sigma_*\,\phi(z), \quad z = \frac{f^* - \mu_*}{\sigma_*}\]
where \(\Phi\) is the standard normal CDF and \(\phi\) is the PDF.
To avoid dynamic memory allocation, the kernel matrix is pre-allocated at compile-time with maximum capacity \(M\). When only \(n < M\) observations are present, the inactive rows and columns are padded with an identity block, preserving positive-definiteness and allowing Gaussian elimination with the full \(M \times M\) system. The resulting alpha vector \(\boldsymbol{\alpha} = (\mathbf{K} + \sigma_n^2 \mathbf{I})^{-1} \mathbf{y}\) has zeros in the inactive entries.
| Case | Time per iteration | Space | Notes |
|---|---|---|---|
| Best | \(O(M^3)\) | \(O(M^2 + NP)\) | Dominated by GP solve |
| Average | \(O(M^3 + C \cdot P)\) | \(O(M^2 + NP)\) | \(C\) candidates, \(P\) parameters |
| Worst | \(O(M^3 + C \cdot P)\) | \(O(M^2 + NP)\) | Same as average |
Where \(M\) = maximum observations, \(C\) = candidate count, \(P\) = parameter count, \(N\) = current number of observations.
All memory is pre-allocated at compile-time on the stack — no dynamic allocation required.
Example: minimize \(f(x) = (x - 0.5)^2\) on \(x \in [0, 1]\).
An optimizer adjusts a model’s parameter vector \(\theta \in \mathbb{R}^P\) to minimize a loss function \(\mathcal{L}(\theta)\). The simplest and most fundamental strategy is gradient descent: repeatedly step in the direction of steepest descent:
\[\theta_{t+1} = \theta_t - \eta \, \nabla_\theta \mathcal{L}(\theta_t)\]
where \(\eta\) is the learning rate — the single most important hyper-parameter in neural network training.
This library provides a batch gradient descent optimizer with a fixed learning rate and maximum iteration count, suitable for small embedded models where training data fits in memory and simplicity is paramount.
The gradient \(\nabla_\theta \mathcal{L}\) is obtained via back-propagation. Each component \(\frac{\partial \mathcal{L}}{\partial \theta_i}\) tells how much the loss changes when \(\theta_i\) is perturbed by a small amount. The gradient points uphill; subtracting it moves downhill.
For a convex loss with Lipschitz-continuous gradient (\(L\)-smooth):
\[\mathcal{L}(\theta_T) - \mathcal{L}(\theta^*) \le \frac{\|\theta_0 - \theta^*\|^2}{2 \eta T}\]
provided \(\eta < \frac{1}{L}\). This gives a convergence rate of \(O(1/T)\).
For non-convex losses (typical in neural networks), gradient descent converges to a stationary point (\(\|\nabla \mathcal{L}\| \approx 0\)) which may be a local minimum or saddle point — not necessarily the global minimum.
| \(\eta\) | Behavior |
|---|---|
| Too small (\(\eta \ll 1/L\)) | Slow convergence, many iterations wasted |
| Optimal (\(\eta \approx 1/L\)) | Fastest reliable convergence |
| Too large (\(\eta > 2/L\)) | Oscillation and divergence |
graph LR
subgraph "Learning Rate Effect"
A["η too small<br/>crawls to minimum"]
B["η just right<br/>smooth convergence"]
C["η too large<br/>oscillates / diverges"]
end
| Operation | Time | Space |
|---|---|---|
| One gradient evaluation | \(O(P)\) per parameter | \(O(P)\) gradient vector |
| One parameter update | \(O(P)\) | In-place |
| Total training (T iterations) | \(O(T \cdot P)\) | \(O(P)\) working memory |
Memory overhead is minimal: only the current parameter vector, the gradient vector, and the optimizer result. No momentum buffers or second-moment estimates.
Setup: 2 parameters, \(\theta_0 = [2.0, -1.0]^T\), \(\eta = 0.1\), loss \(\mathcal{L}(\theta) = \theta_1^2 + \theta_2^2\) (quadratic bowl).
Iteration 1:
| Step | Computation | Result |
|---|---|---|
| Gradient | \(\nabla \mathcal{L} = [2\theta_1, 2\theta_2]\) | \([4.0, -2.0]\) |
| Update | \(\theta_1 = \theta_0 - 0.1 \cdot \nabla\mathcal{L}\) | \([1.6, -0.8]\) |
| Loss | \(\mathcal{L}(\theta_1) = 1.6^2 + 0.8^2\) | \(3.20\) |
Iteration 2:
| Step | Computation | Result |
|---|---|---|
| Gradient | \([3.2, -1.6]\) | |
| Update | \([1.28, -0.64]\) | |
| Loss | \(2.048\) |
Pattern: Each iteration multiplies the loss by \((1 - 2\eta)^2 = 0.64\). After 20 iterations, \(\mathcal{L} \approx 0.0003\).
Convergence trace:
graph LR
I0["θ₀ = (2, -1)<br/>ℒ = 5.0"] --> I1["θ₁ = (1.6, -0.8)<br/>ℒ = 3.2"]
I1 --> I2["θ₂ = (1.28, -0.64)<br/>ℒ = 2.05"]
I2 --> Idots["⋯"]
Idots --> I20["θ₂₀ ≈ (0.01, -0.005)<br/>ℒ ≈ 0.0003"]
maxIterations — always check
result.finalCost to assess convergence quality.| Variant | Key Difference |
|---|---|
| SGD (Stochastic Gradient Descent) | Uses a random mini-batch per iteration; noise helps escape local minima |
| SGD with Momentum | Accumulates a velocity: \(v_{t+1} = \mu v_t - \eta \nabla\mathcal{L}\); smooths out oscillations |
| Nesterov Momentum | Evaluates gradient at the lookahead position \(\theta + \mu v\); faster convergence |
| AdaGrad | Per-parameter adaptive learning rate based on accumulated squared gradients |
| RMSProp | Exponentially decaying average of squared gradients; handles non-stationary objectives |
| Adam | Combines momentum and RMSProp with bias correction; most popular adaptive method |
graph TD
Opt["Optimizer<br/>(Gradient Descent)"]
Loss["Loss Functions"]
Model["Model"]
Reg["Regularization"]
LR["Linear Regression"]
Loss -->|"∇ℒ"| Opt
Model -->|"θ, ∇θ"| Opt
Reg -->|"penalty gradient"| Loss
Opt -.->|"analytical solution at η→∞, 1 step"| LR
| Component | Relationship |
|---|---|
| Loss Functions | Provides the Cost() and Gradient() the
optimizer calls each iteration |
| Model | Passes initial parameters to the optimizer and receives optimized parameters back |
| Regularization | Adds a penalty gradient to \(\nabla\mathcal{L}\), biasing the optimizer toward simpler models |
| Linear Regression | For MSE on a linear model, gradient descent converges to the same solution as the normal equation |
Regularization adds a penalty term \(\Omega(\theta)\) to the loss function that discourages overly complex models:
\[\mathcal{L}_{\text{total}} = \mathcal{L}(\hat{y}, y) + \lambda \, \Omega(\theta)\]
where \(\lambda > 0\) controls the strength of the penalty. Without regularization, a neural network with enough parameters can memorize the training data perfectly yet generalize poorly to new inputs — a phenomenon called overfitting. Regularization biases the optimizer toward simpler solutions that tend to generalize better.
\[\Omega_{L2}(\theta) = \frac{1}{2}\sum_{i=1}^{P} \theta_i^2 = \frac{1}{2}\|\theta\|_2^2\]
\[\frac{\partial \Omega_{L2}}{\partial \theta_i} = \theta_i\]
Effect on update rule:
\[\theta_{t+1} = \theta_t - \eta(\nabla_\theta \mathcal{L} + \lambda \theta_t) = (1 - \eta\lambda)\theta_t - \eta \nabla_\theta \mathcal{L}\]
The factor \((1 - \eta\lambda)\) shrinks weights toward zero each step — hence the name weight decay. Large weights are penalized quadratically, keeping the model smooth.
\[\Omega_{L1}(\theta) = \sum_{i=1}^{P} |\theta_i| = \|\theta\|_1\]
\[\frac{\partial \Omega_{L1}}{\partial \theta_i} = \mathrm{sign}(\theta_i)\]
Effect: L1 drives small weights exactly to zero, producing a sparse model. This is useful for feature selection — irrelevant connections are pruned automatically.
| Property | L1 | L2 |
|---|---|---|
| Penalty shape | Diamond (corners at axes) | Sphere |
| Sparsity | Promotes exact zeros | Shrinks toward zero but rarely reaches it |
| Gradient at \(\theta_i = 0\) | Undefined (sub-gradient) | Zero |
| Best for | Feature selection, sparse models | General-purpose weight control |
The regularized loss can be viewed as constrained optimization:
\[\min_\theta \mathcal{L}(\theta) \quad \text{subject to} \quad \Omega(\theta) \le c\]
where \(c\) is determined by \(\lambda\). L1 constrains \(\theta\) to a diamond, so the optimal point tends to lie at a corner (sparse). L2 constrains to a sphere, so the optimal point balances all dimensions.
| Operation | Time | Space |
|---|---|---|
| \(\Omega_{L2}(\theta)\) | \(O(P)\) | \(O(1)\) |
| \(\nabla \Omega_{L2}\) | \(O(P)\) | \(O(P)\) |
| \(\Omega_{L1}(\theta)\) | \(O(P)\) | \(O(1)\) |
| \(\nabla \Omega_{L1}\) | \(O(P)\) | \(O(P)\) |
Regularization adds negligible computational cost — one pass over the parameter vector per training iteration.
Scenario: 3 parameters, \(\theta = [0.8, -0.3, 0.5]\), \(\lambda = 0.1\).
L2 Regularization:
| Step | Computation | Result |
|---|---|---|
| Penalty | \(\frac{1}{2}(0.64 + 0.09 + 0.25) = 0.49\) | \(\Omega_{L2} = 0.49\) |
| Gradient | \([0.8, -0.3, 0.5]\) | Added to \(\nabla\mathcal{L}\) |
| Contribution to loss | \(0.1 \times 0.49 = 0.049\) |
L1 Regularization:
| Step | Computation | Result |
|---|---|---|
| Penalty | \(0.8 + 0.3 + 0.5 = 1.6\) | \(\Omega_{L1} = 1.6\) |
| Gradient | \([1, -1, 1]\) | Added to \(\nabla\mathcal{L}\) |
| Contribution to loss | \(0.1 \times 1.6 = 0.16\) |
After several L1 updates (\(\eta = 0.1\), \(\lambda = 0.1\)): the \(\theta_2 = -0.3\) component, already small, is driven to exactly zero. The model effectively prunes that connection.
| Variant | Key Difference |
|---|---|
| Elastic Net | \(\Omega = \alpha \|\theta\|_1 + (1-\alpha)\|\theta\|_2^2\); combines L1 sparsity with L2 smoothness |
| Dropout | Randomly zeroes activations during training; implicit ensemble regularization |
| Early stopping | Halts training before overfitting; regularization without modifying the loss |
| Data augmentation | Expands the training set with transformed copies; reduces overfitting by increasing data diversity |
| Spectral normalization | Constrains the spectral norm of weight matrices; stabilizes GAN training |
| Weight clipping | Hard constraint: \(\left\|\theta_i\right\| \le c\); used in Wasserstein GANs |
graph TD
Reg["Regularization"]
Loss["Loss Functions"]
Opt["Optimizer"]
Model["Model"]
LR["Linear Regression"]
Reg -->|"λ Ω(θ) added to ℒ"| Loss
Loss --> Opt
Opt --> Model
Reg -.->|"L2 + MSE = Ridge regression"| LR
| Component | Relationship |
|---|---|
| Loss Functions | Regularization is a penalty added to the loss: \(\mathcal{L}_{\text{total}} = \mathcal{L} + \lambda\Omega\) |
| Optimizer | Receives the combined gradient \(\nabla\mathcal{L} + \lambda\nabla\Omega\) |
| Linear Regression | L2-regularized MSE with a linear model is Ridge regression; L1 is Lasso |
Trigonometric functions, magnitudes, and vector rotations are
fundamental operations in motor control, radar processing, and
navigation. On microcontrollers without a floating-point unit or
hardware multiplier, library implementations of sin,
cos, atan2, and hypot require
expensive software-emulated multiplications. This limits their use in
hard real-time loops where deterministic execution time is
mandatory.
CORDIC (COordinate Rotation DIgital Computer) solves this by expressing any planar rotation as a sum of progressively smaller elementary rotations, each of which requires only a bit-shift and an addition. The result is a trig engine that runs on shift-add hardware with a fixed, data-independent cycle count — exactly what a deterministic real-time loop demands.
A rotation by angle \(\theta\) in two dimensions transforms a vector \((x, y)\) to
\[x' = x\cos\theta - y\sin\theta, \quad y' = y\cos\theta + x\sin\theta.\]
Factoring out \(\cos\theta\) gives
\[x' = \cos\theta\,(x - y\tan\theta), \quad y' = \cos\theta\,(y + x\tan\theta).\]
When \(\tan\theta_i = \pm 2^{-i}\), the multiplication by \(\tan\theta_i\) becomes a right-shift by \(i\) bits. The angles \(\theta_i = \arctan(2^{-i})\) form the CORDIC angle table.
Starting from \((x_0, y_0, z_0) = (K, 0, \theta)\), each iteration steers the residual angle \(z\) toward zero:
\[x_{i+1} = x_i - \sigma_i \, 2^{-i} y_i\] \[y_{i+1} = y_i + \sigma_i \, 2^{-i} x_i\] \[z_{i+1} = z_i - \sigma_i \, \theta_i\]
where \(\sigma_i = \text{sign}(z_i)\). After \(N\) iterations, \(x_N \approx \cos\theta\) and \(y_N \approx \sin\theta\).
Starting from \((x_0, y_0, z_0) = (x, y, 0)\), each iteration steers \(y\) toward zero:
\[\sigma_i = -\text{sign}(y_i)\]
After \(N\) iterations, \(z_N \approx \arctan(y/x)\) and \(x_N \approx \|(x, y)\| / K\).
Each elementary rotation stretches the vector length by \(\sqrt{1 + 2^{-2i}}\). The accumulated gain over \(N\) iterations is
\[A_N = \prod_{i=0}^{N-1} \sqrt{1 + 2^{-2i}}.\]
The constant \(K = 1/A_N \approx 0.6073\) compensates for this growth. In rotation mode the initial \(x\) is pre-scaled by \(K\); in vectoring mode the final \(x\) is multiplied by \(K\).
The convergence domain is \(|z| \leq \sum_{i=0}^{N-1} \arctan(2^{-i})\). For \(N = 16\) this exceeds \(\pi/2\), so inputs outside \([-\pi/2, \pi/2]\) must be range-reduced by shifting the angle by \(\pm\pi\) and inverting the output signs. Vectoring mode uses quadrant detection on the signs of \(x\) and \(y\) to handle the full \([-\pi, \pi]\) range.
| Metric | Value |
|---|---|
| Time | \(O(N)\) — exactly \(N\) shift-add steps per call |
| Space | \(O(N)\) — angle table in ROM; \(O(1)\) working registers |
| Cycle count | Fixed, data-independent — no branch on input value |
One additional bit of precision is gained per iteration. \(N = 16\) yields approximately 16-bit accuracy; \(N = 20\) reaches the limits of single-precision float.
Compute \(\sin(\pi/6) = 0.5\) with \(N = 4\) for brevity (gain \(K_4 \approx 0.6352\)).
| \(i\) | \(\theta_i\) | \(\sigma_i\) | \(x_i\) | \(y_i\) | \(z_i\) |
|---|---|---|---|---|---|
| — | — | — | 0.6352 | 0.0000 | 0.5236 |
| 0 | 0.7854 | +1 | 0.6352 | 0.6352 | −0.2618 |
| 1 | 0.4636 | −1 | 0.7940 | 0.3176 | 0.2018 |
| 2 | 0.2450 | +1 | 0.7147 | 0.5122 | −0.0432 |
| 3 | 0.1244 | −1 | 0.8425 | 0.4248 | 0.0812 |
After iteration 3: \(y_4 \approx 0.43\), improving toward 0.5 as \(N\) grows.
The input to rotation mode must lie within the convergence domain after range reduction. Angles at exactly \(\pm\pi/2\) sit at the edge of the domain and may accumulate an extra half-ulp error.
In vectoring mode, \((x, y) = (0, 0)\) is degenerate; by convention the angle is returned as zero rather than causing a division-by-zero or NaN.
The shift \(2^{-i}\) eventually underflows in floating-point for large \(i\); iterations beyond \(\lfloor -\log_2(\epsilon) \rfloor\) contribute nothing and can be capped without loss of accuracy.
Hyperbolic CORDIC replaces the elementary angle
table with \(\tanh^{-1}(2^{-i})\) and
handles sinh, cosh, exp, and
ln.
Linear CORDIC uses shifts alone (no rotation) to implement multiply and divide.
Double-rotation trick repeats certain iterations to extend the convergence domain to \((-\pi, \pi]\) without a range-reduction step.
sin/cos at carrier frequency.atan2 for heading on
heading-constrained MCUs.TrigonometricFunctions provides a table-lookup
alternative with lower iteration count but higher ROM usage for the same
precision. Quaternion consumes CORDIC-generated
sin/cos for axis-angle conversions. On targets
with an FPU the standard library usually outperforms CORDIC; the
advantage is exclusive to multiply-poor hardware.
Three-dimensional attitude representation is a fundamental requirement in robotics, aerospace, and wearable sensing. Euler angles are intuitive but suffer from gimbal lock — a singularity that collapses three degrees of freedom into two whenever one angle reaches ±90°. Rotation matrices avoid this but carry nine words of state and require orthogonality re-enforcement.
A unit quaternion encodes the same rotation in four words, composes orientations with sixteen multiply-adds, and is free of singularities. Every modern AHRS filter — Madgwick, Mahony, Extended Kalman — stores attitude as a unit quaternion precisely because of this combination of compactness, numerical stability, and algebraic closure.
A quaternion is a hypercomplex number of the form
\[q = w + x\mathbf{i} + y\mathbf{j} + z\mathbf{k}\]
where \(w, x, y, z \in \mathbb{R}\) and the basis elements satisfy
\[\mathbf{i}^2 = \mathbf{j}^2 = \mathbf{k}^2 = \mathbf{ijk} = -1.\]
A unit quaternion (\(\|q\| = 1\)) encodes a rotation by angle \(\theta\) about unit axis \(\hat{n}\) as
\[q = \left(\cos\frac{\theta}{2},\; \hat{n}\sin\frac{\theta}{2}\right).\]
Composition of two rotations \(q_a\) then \(q_b\) is
\[q_a \otimes q_b = \begin{pmatrix} w_a w_b - x_a x_b - y_a y_b - z_a z_b \\ w_a x_b + x_a w_b + y_a z_b - z_a y_b \\ w_a y_b - x_a z_b + y_a w_b + z_a x_b \\ w_a z_b + x_a y_b - y_a x_b + z_a w_b \end{pmatrix}.\]
This product is non-commutative: \(q_a \otimes q_b \neq q_b \otimes q_a\) in general.
A pure quaternion \(p = (0, \mathbf{v})\) is rotated by
\[\mathbf{v}' = q \otimes p \otimes q^{-1}.\]
For unit \(q\) this simplifies (Rodrigues cross-product form) to
\[\mathbf{v}' = \mathbf{v} + 2w\,(\mathbf{u} \times \mathbf{v}) + 2\,\mathbf{u} \times (\mathbf{u} \times \mathbf{v}),\]
where \(\mathbf{u} = (x, y, z)\). This costs 15 multiply-adds vs 9 for a pre-built rotation matrix, making it preferable when rotating one vector.
For any quaternion \(q^* = (w, -x, -y, -z)\). For a unit quaternion \(q^{-1} = q^*\).
\[R(q) = \begin{pmatrix} 1-2(y^2+z^2) & 2(xy-wz) & 2(xz+wy) \\ 2(xy+wz) & 1-2(x^2+z^2) & 2(yz-wx) \\ 2(xz-wy) & 2(yz+wx) & 1-2(x^2+y^2) \end{pmatrix}.\]
Converting from unit quaternion to roll \(\phi\), pitch \(\theta\), yaw \(\psi\):
\[\phi = \mathrm{atan2}(2(wx+yz),\; 1-2(x^2+y^2))\] \[\theta = \arcsin(2(wy-zx))\] \[\psi = \mathrm{atan2}(2(wz+xy),\; 1-2(y^2+z^2))\]
At \(\theta = \pm 90°\) the \(\phi\) and \(\psi\) axes align (gimbal lock); the formula still returns a bounded value but the decomposition is no longer unique.
Spherical Linear Interpolation between unit quaternions \(q_0\) and \(q_1\) at fraction \(t \in [0,1]\):
\[\mathrm{Slerp}(q_0, q_1, t) = \frac{\sin((1-t)\Omega)}{\sin\Omega}\,q_0 + \frac{\sin(t\Omega)}{\sin\Omega}\,q_1,\]
where \(\cos\Omega = q_0 \cdot q_1\). When \(\Omega \approx 0\) (nearly parallel quaternions) the formula degenerates; a normalized linear interpolation (nlerp) is substituted.
| Operation | Time | Space | Notes |
|---|---|---|---|
| Hamilton product | O(1) | O(1) | 16 multiply-adds, scalar only |
| Vector rotate | O(1) | O(1) | 15 multiply-adds via cross-product |
| To rotation matrix | O(1) | O(1) | 9 elements, 16 multiplications |
| From rotation matrix | O(1) | O(1) | Branch on largest diagonal |
| SLERP | O(1) | O(1) | 1 acos + 2 sin + scalar blends |
| Euler conversion | O(1) | O(1) | 2 atan2 + 1 asin |
All operations are stack-only with no heap allocation.
Rotating \(\hat{x} = (1,0,0)\) by 90° about \(\hat{z}\):
Geometry3D (RotationAboutAxis,
CrossProduct) — provides the rotation matrix and vector
primitives reused by quaternion conversions.operator* / Normalize on
every sample.acos/sin for fixed-point axis-angle
conversions on cores without an FPU.Linear algebra computations — solvers, Kalman filter covariance updates, regression — depend on the numerical health of the matrices involved. Norms formalise the notion of “size” for matrices and vectors and are the building blocks of every conditioning, stability, and error-bound estimate in the library. They are also the cheapest such quantities to compute: a single pass over the entries with no allocation, suitable for real-time embedded paths.
Vector normalisation, closely related, produces the unit-length direction of a vector and is a recurring primitive in geometry, attitude estimation, and gradient methods.
For vectors \(\mathbf{a}, \mathbf{b} \in \mathbb{R}^n\), the dot product is \(\mathbf{a}\cdot\mathbf{b} = \sum_{i=1}^{n} a_i b_i\). The Euclidean (L2) norm is the square root of the self dot product:
\[\|\mathbf{v}\|_2 = \sqrt{\mathbf{v}\cdot\mathbf{v}} = \sqrt{\sum_{i=1}^{n} v_i^2}\]
VectorNorm is implemented in terms of
DotProduct. The unit vector \(\hat{\mathbf{v}} = \mathbf{v} /
\|\mathbf{v}\|_2\) satisfies \(\|\hat{\mathbf{v}}\|_2 = 1\). Normalisation
is undefined when \(\|\mathbf{v}\|_2 =
0\) and must be guarded.
Frobenius norm — treats the matrix as a flattened vector:
\[\|A\|_F = \sqrt{\sum_{i=1}^{m}\sum_{j=1}^{n} a_{ij}^2}\]
It is rotationally invariant under unitary transformations and cheap to compute.
1-norm (maximum absolute column sum):
\[\|A\|_1 = \max_{1 \le j \le n} \sum_{i=1}^{m} |a_{ij}|\]
Infinity norm (maximum absolute row sum):
\[\|A\|_\infty = \max_{1 \le i \le m} \sum_{j=1}^{n} |a_{ij}|\]
The 1-norm and infinity-norm are dual: \(\|A\|_\infty = \|A^\top\|_1\).
| Operation | Time | Space | Notes |
|---|---|---|---|
| DotProduct | \(O(n)\) | \(O(1)\) | Single pass; VectorNorm builds on it |
| FrobeniusNorm | \(O(mn)\) | \(O(1)\) | Single pass, no allocation |
| OneNorm | \(O(mn)\) | \(O(1)\) | Column-wise sum, running max |
| InfinityNorm | \(O(mn)\) | \(O(1)\) | Row-wise sum, running max |
| VectorNorm | \(O(n)\) | \(O(1)\) | Single pass |
| Normalize | \(O(n)\) | \(O(n)\) | Output vector on stack |
Matrix \(A = \begin{bmatrix}3 & 1 \\ 1 & 2\end{bmatrix}\):
Vector \(\mathbf{v} = [3,\, 4]^\top\): \(\|\mathbf{v}\|_2 = 5\), and \(\hat{\mathbf{v}} = [0.6,\, 0.8]^\top\).
Zero vector normalisation — dividing by \(\|\mathbf{v}\|_2 = 0\) is undefined. The implementation returns an empty optional for near-zero norms.
Fast-math semantics —
#pragma GCC optimize("fast-math") may reorder
floating-point operations. The norms are sums of non-negative values, so
reordering does not change the sign of the result, but catastrophic
cancellation can still occur for near-zero off-diagonal entries.
Non-square matrices — FrobeniusNorm, OneNorm, and InfinityNorm apply to any \(m \times n\) matrix.
The spectral norm (largest singular value, \(\|A\|_2\)) is the tightest but requires an SVD — \(O(N^3)\) with a large constant, unsuitable for embedded real-time paths. The 1-norm and infinity-norm are cheap upper bounds used throughout the library instead.
General p-norms and weighted norms generalise the vector case; the L2 norm is the only one currently exposed because it is the natural quantity for geometric and least-squares work.
solvers::ConditionNumber).The 1-norm is the norm used by solvers::ConditionNumber
for its \(\|A\|\cdot\|A^{-1}\|\)
estimate. The math::Matrix type provides the storage and
transpose the norms operate on. Norm-based residual tests appear in
solvers and optimization.
A Householder reflector is the workhorse of numerically stable dense
linear algebra. A single reflector mirrors a vector onto a coordinate
axis, zeroing every entry below a chosen pivot in one orthogonal step.
Chaining reflectors triangularizes a matrix (QR), bidiagonalizes it
(SVD), or tridiagonalizes a symmetric matrix (eigensolvers).
HouseholderVector computes the reflector for one
sub-column; it is the shared primitive those factorizations call.
For a vector \(x\), the Householder reflector is the orthogonal matrix
\[H = I - \beta\, v v^\top\]
chosen so that \(Hx\) is zero below the pivot. With \(\sigma = \sum_{i>\text{start}} x_i^2\) and \(\|x\| = \sqrt{x_{\text{start}}^2 + \sigma}\), the reflector maps \(x_{\text{start}} \mapsto \mp\|x\|\). The pivot sign is chosen as \(-\mathrm{sign}(x_{\text{start}})\|x\|\) to avoid cancellation:
\[v_{\text{start}} = 1, \quad v_i = x_i / v_0, \quad \beta = \frac{2 v_0^2}{\sigma + v_0^2}\]
\(H\) is symmetric and orthogonal (\(H = H^\top = H^{-1}\)), so applying it is backward stable.
| Operation | Time | Space | Notes |
|---|---|---|---|
| HouseholderVector | \(O(n)\) | \(O(1)\) | Builds v (stored implicit unit pivot) |
| Apply \(H\) to a vector | \(O(n)\) | \(O(1)\) | x - β·v·(vᵀx) — never form H |
For \(x = [4, 3, 0, 0]^\top\), pivot
start = 0: \(\sigma = 9\),
\(\|x\| = 5\). Since \(x_0 > 0\), \(v_0 = -\sigma/(x_0 + \|x\|) = -1\), giving
\(\beta = 1\) and \(v = [1, -3, 0, 0]^\top\). Reflecting yields
\(Hx = [-5, 0, 0, 0]^\top\) — the
sub-column collapsed onto the axis.
Already-zero sub-column — when \(\sigma \approx 0\) there is nothing to zero; the routine returns \(\beta = 0\) (identity reflector) and callers skip the update.
Never form H explicitly — apply it as
x − β·v·(vᵀx) to keep the cost \(O(n)\) per column instead of \(O(n^2)\).
Float-only —
static_assert(std::is_floating_point_v<T>); the
sign-fixing and normalisation assume real floating-point arithmetic.
Givens rotations (math::GivensRotation) achieve the same
zeroing one entry at a time, preferable for sparse or streaming updates.
Complex Householder reflectors extend this to unitary
triangularization.
A.Used by solvers::QrDecomposition; complements
math::GivensRotation and
math::SolveUpperTriangular. Operates on
math::Vector.
A Givens rotation zeros a single matrix entry by rotating two rows in
a plane, leaving all others untouched. Because it touches only two rows,
it is the tool of choice for incremental linear algebra:
streaming a new row into an existing QR factor, sparse
triangularization, and the implicit-shift sweeps of QR/SVD
eigen-iterations. ComputeGivens derives the rotation
coefficients; ApplyGivens applies them to a scalar
pair.
Given two scalars \(a\) (the value to keep) and \(b\) (the value to zero), the rotation
\[G = \begin{bmatrix} c & s \\ -s & c \end{bmatrix}, \quad c = \frac{a}{r},\; s = \frac{b}{r},\; r = \sqrt{a^2 + b^2}\]
is orthogonal (\(c^2 + s^2 = 1\)) and satisfies
\[G \begin{bmatrix} a \\ b \end{bmatrix} = \begin{bmatrix} r \\ 0 \end{bmatrix}\]
Applied across a pair of rows, it zeros the target entry while preserving Euclidean length.
| Operation | Time | Space | Notes |
|---|---|---|---|
| ComputeGivens | \(O(1)\) | \(O(1)\) | One sqrt, two divides |
| ApplyGivens | \(O(1)\) | \(O(1)\) | Rotates one scalar pair |
| Rotate two rows | \(O(n)\) | \(O(1)\) | ApplyGivens across n columns |
ComputeGivens(3, 4): \(r =
5\), \(c = 0.6\), \(s = 0.8\). Applying to \((x, y) = (3, 4)\): \(x' = 0.6\cdot 3 + 0.8\cdot 4 = 5\),
\(y' = -0.8\cdot 3 + 0.6\cdot 4 =
0\) — the second component is annihilated and the norm is
preserved.
Degenerate pair — when \(a
= b = 0\) the rotation is undefined; ComputeGivens
returns the identity \((c, s) = (1,
0)\) so applying it is a safe no-op.
Float-only —
static_assert(std::is_floating_point_v<T>).
Householder reflectors (math::HouseholderTransform) zero
an entire sub-column at once and are cheaper for dense factorization;
Givens wins when only one entry (or one streamed row) changes.
Fast/“square-root-free” Givens variants trade the sqrt for
extra bookkeeping.
R
(QrDecomposition::GivensUpdateRow).Used by solvers::QrDecomposition; complements
math::HouseholderTransform.
Nearly every dense linear solver ends with a triangular system.
Gaussian elimination reduces Ax = b to an upper-triangular
Ux = c; QR factorization solves least squares through
Rx = Qᵀb; Cholesky solves two triangular systems
back-to-back. SolveUpperTriangular is the shared
back-substitution kernel these routines call so the algorithm lives in
exactly one place.
Given an upper-triangular matrix \(R \in \mathbb{R}^{n \times n}\) (entries below the diagonal ignored) and a right-hand side \(c\), back-substitution solves \(Rx = c\) bottom-up:
\[x_i = \frac{1}{r_{ii}}\left(c_i - \sum_{j=i+1}^{n} r_{ij}\, x_j\right), \quad i = n, n-1, \dots, 1\]
Each unknown depends only on those already computed, so a single reverse sweep suffices.
| Operation | Time | Space | Notes |
|---|---|---|---|
| SolveUpperTriangular | \(O(n^2)\) | \(O(n)\) | One reverse sweep, stack output |
For \(R = \begin{bmatrix}2 & -1 & 3 \\ 0 & 4 & 1 \\ 0 & 0 & 5\end{bmatrix}\), \(c = [10,\, -5,\, 15]^\top\):
Singular / near-zero pivot — a zero diagonal entry
makes the system unsolvable. The routine asserts
|r_{ii}| > 0 via really_assert; callers
(GaussianElimination, QrDecomposition) detect
rank deficiency before reaching it.
Generic on T — the kernel is templated
on any supported numeric type (float, Q15,
Q31) because Gaussian elimination is instantiated for all
three; it uses math::ToFloat only for the pivot
assertion.
A lower-triangular forward-substitution is the mirror image (top-down sweep) and can be added when Cholesky/LU forward solves need it. Block triangular solves generalise this to matrix right-hand sides.
Rx = Qᵀb.Shared by solvers::GaussianElimination and
solvers::QrDecomposition. Operates on
math::Matrix / math::Vector.
Small structural matrix utilities that are needed across the library but do not belong to any single algorithm:
Symmetrize enforces exact symmetry on
a matrix that should be symmetric in theory but drifts under
floating-point round-off.CongruenceTransform computes the
quadratic form A·M·Aᵀ — the single most common shape in
covariance-propagating code (Kalman predict F·P·Fᵀ,
innovation covariance H·P·Hᵀ, Joseph update
(I−KH)·P·(I−KH)ᵀ, etc.).Both are recurring needs in Kalman filters, EM parameter updates, and Riccati/Lyapunov solvers.
Any square matrix decomposes into a symmetric and a skew-symmetric part:
\[M = \underbrace{\tfrac{1}{2}(M + M^\top)}_{\text{symmetric}} + \underbrace{\tfrac{1}{2}(M - M^\top)}_{\text{skew}}\]
Symmetrize returns the symmetric part \(\tfrac{1}{2}(M + M^\top)\). It is the
orthogonal projection (in the Frobenius inner product) of \(M\) onto the subspace of symmetric
matrices, so it is the closest symmetric matrix to \(M\). Applying it to an already-symmetric
matrix is a no-op (idempotent).
If \(M\) is symmetric,
CongruenceTransform returns a symmetric result exactly (in
exact arithmetic): \((AMA^\top)^\top = A
M^\top A^\top = A M A^\top\). This makes it the natural building
block for propagating a covariance \(P\) through a linear map \(A\): \(P \mapsto
A P A^\top\).
| Operation | Time | Space | Notes |
|---|---|---|---|
| Symmetrize | \(O(n^2)\) | \(O(n^2)\) | One transpose, add, scale |
| CongruenceTransform | \(O(n^2 m + n m^2)\) | \(O(nm)\) | For \(A \in \mathbb{R}^{n\times m}\), two matrix products |
Symmetrize — for \(M = \begin{bmatrix}1 & 3 \\ -1 & 2\end{bmatrix}\): \(M^\top = \begin{bmatrix}1 & -1 \\ 3 & 2\end{bmatrix}\), so \(\tfrac{1}{2}(M + M^\top) = \begin{bmatrix}1 & 1 \\ 1 & 2\end{bmatrix}\) — off-diagonals averaged, diagonal unchanged.
CongruenceTransform — with \(A \in \mathbb{R}^{n\times m}\) and symmetric \(M \in \mathbb{R}^{m\times m}\), the result \(A M A^\top \in \mathbb{R}^{n\times n}\) is the covariance of \(A x\) when \(x\) has covariance \(M\).
Symmetrize is not a fix for indefiniteness — it
removes the skew part but does not make a matrix positive-definite;
covariance code typically also adds a small diagonal jitter
(+ εI) separately.
CongruenceTransform association — the implementation
evaluates (A·M)·Aᵀ, matching the left-associative
operator*; results are bit-identical to hand-written
A * M * A.Transpose(). Under fast-math the
symmetry of the output can still carry tiny round-off asymmetry — follow
with Symmetrize when exact symmetry is required.
Float-only — both are
static_assert(std::is_floating_point_v<T>).
The skew-symmetric part \(\tfrac{1}{2}(M -
M^\top)\) is Symmetrize’s companion. The transposed
congruence \(A^\top M A\) (used by
DiscreteAlgebraicRiccatiEquation) is obtained by passing
A.Transpose().
F·P·Fᵀ,
H·P·Hᵀ, Joseph form (I−KH)·P·(I−KH)ᵀ + K·R·Kᵀ
across the KF/EKF/UKF/smoother family.Q, R, P after asymmetric matrix
products (estimators::ExpectationMaximization).Operate on math::Matrix /
math::SquareMatrix. Consumed by the
filters::active Kalman family and
estimators::ExpectationMaximization;
CongruenceTransform pairs naturally with
Symmetrize for covariance-positivity hygiene.
When a control system or filter receives a step input, its output traces a transient trajectory before settling at the final value. Quantifying that trajectory with standardised scalar metrics — rise time, settling time, percent overshoot, peak time, and steady-state error — is the primary acceptance test for any closed-loop design. These metrics translate the raw sample sequence into the language of control specifications, allowing automated pass/fail decisions without manual inspection of time-domain plots.
Let \(y[k]\), \(k = 0, \ldots, N-1\) be the sampled step response and \(y_{ss}\) the steady-state value. The sample period is \(\Delta t\).
Rise Time \(T_r\)
The elapsed time for the response to travel from 10 % to 90 % of steady state:
\[T_r = (k_{90} - k_{10})\,\Delta t\]
where \(k_{10} = \min\{k : y[k] \ge 0.1\,y_{ss}\}\) and \(k_{90} = \min\{k \ge k_{10} : y[k] \ge 0.9\,y_{ss}\}\).
Settling Time \(T_s\)
The first time after which the response remains permanently inside the band \([(1-\delta)y_{ss},\,(1+\delta)y_{ss}]\) (typically \(\delta = 0.02\)):
\[T_s = (k^* + 1)\,\Delta t, \quad k^* = \max\{k : |y[k] - y_{ss}| > \delta\,|y_{ss}|\}\]
Percent Overshoot \(\%OS\)
\[\%OS = 100\,\frac{y_{\max} - y_{ss}}{y_{ss}}, \quad y_{\max} = \max_k y[k]\]
For an underdamped second-order system with damping ratio \(\zeta\):
\[\%OS = 100\,\exp\!\left(-\frac{\pi\zeta}{\sqrt{1-\zeta^2}}\right)\]
Peak Time \(T_p\)
\[T_p = k_p\,\Delta t, \quad k_p = \arg\max_k y[k]\]
For a continuous underdamped second-order system with natural frequency \(\omega_n\):
\[T_p = \frac{\pi}{\omega_n\sqrt{1-\zeta^2}}\]
Steady-State Error \(e_{ss}\)
\[e_{ss} = r - \bar{y}_{\text{tail}}\]
where \(r\) is the reference (command) value and \(\bar{y}_{\text{tail}}\) is the mean of the final quarter of the response buffer, providing a robust estimate of the achieved steady state.
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(N)\) | \(O(1)\) | Single forward pass; no auxiliary storage |
Each metric requires at most one traversal of the \(N\)-element vector. The tail-mean for steady-state error adds a constant-fraction second scan of the same data — still \(O(N)\) total.
Consider a 10-sample ramp to \(y_{ss} = 1\) followed by a constant plateau (N = 20):
k: 0 1 2 3 4 5 6 7 8 9 10 11 …
y: 0 .1 .2 .3 .4 .5 .6 .7 .8 .9 1 1 …
Zero steady state. Division by \(y_{ss}\) in percent overshoot is guarded; the function returns zero when \(y_{ss} = 0\) to avoid a NaN.
Non-monotone ramp. If the response crosses 90 % before 10 % (e.g., DC offset or wrong initial condition), \(k_{10}\) may be found after the first 90 % crossing. The implementation returns the first pair that satisfies the threshold order.
Oscillatory settling. Settling time is defined as the last time the trajectory leaves the band, not the first time it enters it. Repeated crossings near the boundary extend the metric correctly.
Finite buffer. With a bounded vector of length \(N\), if the response has not yet settled by
the final sample, SettlingTime returns \(N\,\Delta t\) and RiseTime
returns \((N-1)\,\Delta t\) as
conservative bounds.
Tail-mean length. Using the last \(\lfloor N/4 \rfloor + 1\) samples for the steady-state estimate assumes the transient has decayed to within numerical noise by that point. Poorly chosen \(N\) relative to the system time constant degrades the estimate.
Mean function on a
sub-range.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.
For a square matrix \(A \in \mathbb{R}^{n \times n}\), the matrix exponential is defined by the power series
\[e^A = \sum_{k=0}^{\infty} \frac{A^k}{k!} = I + A + \frac{A^2}{2!} + \frac{A^3}{3!} + \cdots\]
The series converges for every finite matrix and every field of characteristic zero.
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
\[e^A = \left(e^{A/2^s}\right)^{2^s}\]
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.
For the scaled matrix \(\hat{A} = A/2^s\), the exponential is approximated by the diagonal \((q, q)\) Padé rational function
\[e^{\hat{A}} \approx R_q(\hat{A}) = D_q(\hat{A})^{-1} N_q(\hat{A})\]
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
\[c_k = \frac{(2q - k)!\, q!}{(2q)!\, k!\, (q-k)!}, \quad k = 0, 1, \ldots, q\]
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}\).
The even/odd split halves the number of matrix multiplications:
\[V = c_0 I + c_2 \hat{A}^2 + c_4 \hat{A}^4 + c_6 \hat{A}^6\] \[U = \hat{A}\!\left(c_1 I + c_3 \hat{A}^2 + c_5 \hat{A}^4\right)\] \[N_q = V + U, \quad D_q = V - U\]
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.
| Phase | Time | Space | Notes |
|---|---|---|---|
| Infinity-norm | \(O(n^2)\) | \(O(1)\) | Determines scaling exponent \(s\) |
| Matrix powers \(A^2, A^4, A^6\) | \(3\,O(n^3)\) | \(3 n^2\) | Even/odd split; three multiplications total |
| Polynomial evaluation | \(O(n^3)\) | \(2 n^2\) | Horner-style accumulation of \(U\) and \(V\) |
| LU solve (\(D_q X = N_q\)) | \(O(n^3)\) | \(n^2\) | One factorisation, \(n\) substitution passes |
| Squaring (\(s\) steps) | \(s\,O(n^3)\) | \(n^2\) | At most \(\lceil \log_2 \|A\|_\infty \rceil\) steps |
| Total | \(O((6+s)n^3)\) | \(O(n^2)\) | Stack-only; no heap |
Consider \(A = \begin{bmatrix}0 & -1 \\ 1 & 0\end{bmatrix}\), a rotation generator with \(\theta = 1\).
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.
Nilpotent matrices: the series terminates in finitely many terms. Scaling-and-squaring handles this transparently; the Padé approximant reduces to a truncated polynomial.
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.
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.
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.
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.
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.
Taylor series with Horner evaluation: accurate only for \(\|A\| \ll 1\); the scaling step achieves exactly this, making scaling-and-squaring a superset.
ContinuousToDiscrete): \(A_d =
e^{A_c \Delta t}\).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.
The Cholesky decomposition factors a symmetric positive-definite matrix \(A\) into the product \(A = LL^T\), where \(L\) is a lower triangular matrix with positive diagonal entries. This is the matrix equivalent of taking a “square root.”
It is approximately twice as fast as LU decomposition for symmetric positive-definite systems and is numerically stable without pivoting. In this library, it is used primarily by the Unscented Kalman Filter to generate sigma points from the state covariance matrix.
Given \(A \in \mathbb{R}^{n \times n}\) symmetric positive-definite, compute \(L\) such that \(A = LL^T\):
\[L_{jj} = \sqrt{A_{jj} - \sum_{k=0}^{j-1} L_{jk}^2}\]
\[L_{ij} = \frac{1}{L_{jj}} \left( A_{ij} - \sum_{k=0}^{j-1} L_{ik} L_{jk} \right), \quad i > j\]
| Operation | Time | Space | Notes |
|---|---|---|---|
| Decomposition | \(O(n^3/6)\) | \(O(n^2)\) | Half the cost of general LU decomposition |
Input: \(A = \begin{bmatrix} 4 & 2 \\ 2 & 5 \end{bmatrix}\) (symmetric positive-definite).
Compute \(L\) column by column:
| Step | Formula | Value |
|---|---|---|
| \(L_{00}\) | \(\sqrt{A_{00}} = \sqrt{4}\) | \(2\) |
| \(L_{10}\) | \(A_{10} / L_{00} = 2 / 2\) | \(1\) |
| \(L_{11}\) | \(\sqrt{A_{11} - L_{10}^2} = \sqrt{5 - 1}\) | \(2\) |
Result:
\[L = \begin{bmatrix} 2 & 0 \\ 1 & 2 \end{bmatrix}\]
Verify: \(LL^T = \begin{bmatrix} 2 & 0 \\ 1 & 2 \end{bmatrix} \begin{bmatrix} 2 & 1 \\ 0 & 2 \end{bmatrix} = \begin{bmatrix} 4 & 2 \\ 2 & 5 \end{bmatrix} = A\) ✓
sqrt is expensive or imprecise.| Algorithm | Relationship |
|---|---|
| Unscented Kalman Filter | Uses Cholesky to generate sigma points from the covariance matrix |
| Gaussian Elimination | General-purpose alternative; does not exploit symmetry |
Gaussian elimination is the standard direct method for solving a system of linear equations \(Ax = b\). It transforms the coefficient matrix into upper-triangular form through systematic row operations, then solves via back-substitution.
It serves as the foundational linear solver in this library — used inside the normal equation solver for Linear Regression, inside the DARE iteration for LQR, and anywhere else a small dense linear system arises at runtime.
The implementation uses partial pivoting (selecting the largest-magnitude entry in each column as pivot) to improve numerical stability.
Solve \(Ax = b\) where \(A \in \mathbb{R}^{n \times n}\) is non-singular, \(b \in \mathbb{R}^n\).
For each column \(k = 0, \ldots, n-1\):
\[\ell_{ik} = \frac{A_{ik}}{A_{kk}}\] \[A_{ij} \leftarrow A_{ij} - \ell_{ik} \cdot A_{kj}, \quad j = k, \ldots, n-1\] \[b_i \leftarrow b_i - \ell_{ik} \cdot b_k\]
After all columns are processed, \(A\) is upper-triangular: \(Ux = b'\).
\[x_i = \frac{b'_i - \sum_{j=i+1}^{n-1} U_{ij} \, x_j}{U_{ii}}, \quad i = n-1, \ldots, 0\]
For \(AX = B\) where \(B\) has \(m\) columns, each column is solved independently.
| Phase | Time | Space | Notes |
|---|---|---|---|
| Forward elimination | \(O(n^3)\) | \(O(n^2)\) | In-place on copies of \(A\) and \(b\) |
| Back-substitution | \(O(n^2)\) | \(O(n)\) | |
| Multi-column solve | \(O(n^3 + n^2 m)\) | \(O(n^2)\) | \(m\) back-substitutions |
Why \(O(n^3)\): The elimination has \(n\) stages; stage \(k\) performs \((n-k)^2\) multiplications. Summing: \(\sum_{k=1}^{n} k^2 \approx n^3/3\).
System:
\[\begin{bmatrix} 2 & 1 & -1 \\ -3 & -1 & 2 \\ -2 & 1 & 2 \end{bmatrix} \begin{bmatrix} x_1 \\ x_2 \\ x_3 \end{bmatrix} = \begin{bmatrix} 8 \\ -11 \\ -3 \end{bmatrix}\]
Step 1 — Column 0: pivot selection
\(|A_{00}| = 2\), \(|A_{10}| = 3\) (largest), \(|A_{20}| = 2\). Swap rows 0 and 1:
\[\begin{bmatrix} -3 & -1 & 2 \\ 2 & 1 & -1 \\ -2 & 1 & 2 \end{bmatrix}, \quad b = \begin{bmatrix} -11 \\ 8 \\ -3 \end{bmatrix}\]
Step 2 — Eliminate below pivot
Step 3 — Column 1: pivot selection
\(|1/3| < |5/3|\). Swap rows 1 and 2.
Step 4 — Eliminate below:
\(\ell = (1/3)/(5/3) = 1/5\). Row 2 -= \((1/5)\) × Row 1 → \([0,\; 0,\; 1/5 \mid -1/5]\). Upper-triangular form reached.
Step 5 — Back-substitution:
\[x_3 = \frac{-1/5}{1/5} = -1, \qquad x_2 = \frac{13/3 - (2/3)(-1)}{5/3} = 3, \qquad x_1 = \frac{-11 - (-1)(3) - 2(-1)}{-3} = 2\]
Verification: \(2(2) + 1(3) + (-1)(-1) = 8\) ✓, \(\;-3(2) - 1(3) + 2(-1) = -11\) ✓, \(\;-2(2) + 1(3) + 2(-1) = -3\) ✓.
| Variant | Key Difference |
|---|---|
| Full pivoting | Pivots on both rows and columns; more stable but rarely needed in practice |
| LU factorization | Stores the \(L\) and \(U\) factors for reuse; solves multiple right-hand sides efficiently |
| Cholesky factorization | Specialized for symmetric positive-definite matrices; \(O(n^3/6)\) instead of \(O(n^3/3)\) |
| Gauss-Jordan elimination | Reduces to reduced row echelon form (identity matrix); used for matrix inversion |
| Iterative refinement | Solves once, then iteratively corrects the residual to improve accuracy |
graph LR
GE["Gaussian Elimination"]
LR["Linear Regression"]
DARE["DARE Solver"]
LQR["LQR Controller"]
LD["Levinson-Durbin"]
LR --> GE
DARE --> GE
LD -.->|"Toeplitz-specialized alternative"| GE
| Algorithm | Relationship |
|---|---|
| Linear Regression | Uses Gaussian elimination to solve the normal equation |
| DARE Solver | Calls Gaussian elimination at each Riccati iteration |
| Levinson-Durbin | Specialized \(O(n^2)\) solver for Toeplitz systems; Gaussian elimination is the \(O(n^3)\) fallback |
Many problems in signal processing produce linear systems with Toeplitz structure — each descending diagonal contains the same value. Autocorrelation matrices, for example, are always Toeplitz and symmetric. General solvers like Gaussian elimination ignore this structure and cost \(O(n^3)\).
The Levinson-Durbin algorithm exploits the Toeplitz structure to solve such systems in \(O(n^2)\) time and \(O(n)\) auxiliary space. It works by solving an order-1 system first, then growing the solution to order 2, 3, …, up to \(n\), reusing previous solutions at each step via a recursion involving reflection coefficients.
This algorithm is central to AR model estimation (Yule-Walker) and linear prediction in speech processing.
A symmetric Toeplitz matrix and the system to solve:
\[\begin{bmatrix} r_0 & r_1 & r_2 & \cdots & r_{n-1} \\ r_1 & r_0 & r_1 & \cdots & r_{n-2} \\ r_2 & r_1 & r_0 & \cdots & r_{n-3} \\ \vdots & & & \ddots & \vdots \\ r_{n-1} & r_{n-2} & r_{n-3} & \cdots & r_0 \end{bmatrix} \begin{bmatrix} x_1 \\ x_2 \\ \vdots \\ x_n \end{bmatrix} = \begin{bmatrix} b_1 \\ b_2 \\ \vdots \\ b_n \end{bmatrix}\]
Define \(\varphi^{(k)}\) as the solution of the order-\(k\) system. The algorithm computes:
Reflection coefficient at order \(k\):
\[\mu_k = \frac{b_k - \sum_{j=0}^{k-1} \varphi_j^{(k-1)} \, r_{k-j}}{e_{k-1}}\]
Solution update:
\[\varphi_j^{(k)} = \varphi_j^{(k-1)} + \mu_k \, \varphi_{k-1-j}^{(k-1)}, \quad 0 \leq j < k\] \[\varphi_k^{(k)} = \mu_k\]
Prediction error update:
\[e_k = e_{k-1}(1 - \mu_k^2)\]
Starting from \(e_0 = r_0\) and \(\varphi_0^{(0)} = b_0 / r_0\).
If \(|\mu_k| < 1\) for all \(k\), the corresponding AR model is stable (all poles inside the unit circle) and the Toeplitz matrix is positive definite.
| Case | Time | Space | Notes |
|---|---|---|---|
| All | \(O(n^2)\) | \(O(n)\) | Versus \(O(n^3)\) for general solvers |
Why \(O(n^2)\): At order \(k\), computing \(\mu_k\) costs \(O(k)\) and updating the solution costs \(O(k)\). Summing over \(k = 1, \ldots, n\): \(\sum k = n(n+1)/2 = O(n^2)\).
System: \(R x = b\) where \(r = [4, 2, 1]\), \(b = [2, 1, 0.5]\), \(n = 3\).
\[\begin{bmatrix} 4 & 2 & 1 \\ 2 & 4 & 2 \\ 1 & 2 & 4 \end{bmatrix} x = \begin{bmatrix} 2 \\ 1 \\ 0.5 \end{bmatrix}\]
Step 0 — Initialize:
\(e_0 = r_0 = 4\), \(\varphi_0^{(0)} = b_0 / r_0 = 2/4 = 0.5\)
Step 1 — Order 2:
\(\mu_1 = \frac{b_1 - \varphi_0^{(0)} \cdot r_1}{e_0} = \frac{1 - 0.5 \cdot 2}{4} = \frac{0}{4} = 0\)
\(\varphi_0^{(1)} = \varphi_0^{(0)} + 0 \cdot \varphi_0^{(0)} = 0.5\), \(\varphi_1^{(1)} = 0\)
\(e_1 = 4(1 - 0) = 4\)
Step 2 — Order 3:
\(\mu_2 = \frac{b_2 - (\varphi_0^{(1)} r_2 + \varphi_1^{(1)} r_1)}{e_1} = \frac{0.5 - (0.5 \cdot 1 + 0 \cdot 2)}{4} = \frac{0}{4} = 0\)
\(\varphi^{(2)} = [0.5, 0, 0]\)
Solution: \(x = [0.5, 0, 0]^T\)
Verification: \(4(0.5) + 2(0) + 1(0) = 2\) ✓, \(2(0.5) + 4(0) + 2(0) = 1\) ✓
| Variant | Key Difference |
|---|---|
| Split Levinson | Replaces the two-vector recursion with a single vector; slightly more efficient |
| Block Levinson | Solves block-Toeplitz systems (matrix entries instead of scalars) |
| Burg’s method | Computes reflection coefficients from data rather than autocovariances; often more accurate for short records |
| Schur algorithm | Computes reflection coefficients without forming the full solution; useful for VLSI implementations |
| Superfast Toeplitz solvers | \(O(n \log^2 n)\) algorithms based on FFT and divide-and-conquer |
graph LR
LD["Levinson-Durbin"]
YW["Yule-Walker"]
GE["Gaussian Elimination"]
PSD["Power Spectral Density"]
YW --> LD
GE -.->|"general alternative"| LD
LD -.->|"parametric PSD"| PSD
| Algorithm | Relationship |
|---|---|
| Yule-Walker | Primary consumer — produces the Toeplitz system that Levinson-Durbin solves |
| Gaussian Elimination | General \(O(n^3)\) alternative that ignores Toeplitz structure |
| Power Spectral Density | Levinson-Durbin enables parametric PSD estimation as an alternative to Welch’s non-parametric method |
Given a polynomial \(P(z) = c_0 z^n + c_1 z^{n-1} + \cdots + c_n\), finding all roots (real and complex) is a fundamental problem in control theory, signal processing, and numerical analysis. Analytical formulas exist only for degree \(\leq 4\); beyond that, iterative methods are required.
The Durand-Kerner method (also known as the Weierstrass iteration) simultaneously refines approximations to all roots at once. Each root estimate is updated using Newton’s method, but instead of computing the derivative of \(P\), the other root estimates are used to factor out known roots. This avoids polynomial deflation — a process that accumulates errors as roots are extracted sequentially.
The method is elegant, easy to implement, and works for polynomials with complex coefficients and complex roots.
Given current root estimates \(z_0, z_1, \ldots, z_{n-1}\), each is updated simultaneously:
\[z_r^{(k+1)} = z_r^{(k)} - \frac{P(z_r^{(k)})}{\displaystyle\prod_{j \neq r} \left(z_r^{(k)} - z_j^{(k)}\right)}\]
For a polynomial \(P(z) = c_0 \prod_{j=0}^{n-1}(z - z_j)\), the derivative is:
\[P'(z_r) = c_0 \prod_{j \neq r}(z_r - z_j)\]
So the Durand-Kerner update is exactly Newton’s: \(z_r - P(z_r)/P'(z_r)\), but with \(P'\) approximated using the current root estimates rather than computed from coefficients.
Roots are initialized on a circle of radius:
\[R = \left|\frac{c_n}{c_0}\right|^{1/n}\]
with angular positions offset by 0.4 radians to break symmetry. If \(R < 0.1\), it defaults to 1.0.
| Case | Time | Space | Notes |
|---|---|---|---|
| Per iteration | \(O(n^2)\) | \(O(n)\) | Evaluating \(P\) and the denominator product for all \(n\) roots |
| Total | \(O(n^2 k)\) | \(O(n)\) | \(k\) iterations until convergence (typically \(k \ll n\)) |
Why \(O(n^2)\): For each of the \(n\) roots, computing the denominator product requires multiplying \(n-1\) terms.
Polynomial: \(P(z) = z^3 - 6z^2 + 11z - 6 = (z-1)(z-2)(z-3)\)
Step 1 — Initialize on radius \(R = |{-6}/{1}|^{1/3} = 6^{1/3} \approx 1.817\):
Step 2 — Iteration 1 (for \(z_0\)):
Step 3 — Repeat for \(z_1\) and \(z_2\) (using the latest values).
Step 4 — Iterate until \(\max_r |z_r^{(k+1)} - z_r^{(k)}| < 10^{-6}\).
After ~15–20 iterations the roots converge to \(z \approx \{1, 2, 3\}\) (imaginary parts \(< 10^{-6}\)).
float,
double). Fixed-point types are not supported.| Variant | Key Difference |
|---|---|
| Aberth-Ehrlich method | Similar simultaneous iteration but includes a correction term that improves convergence for clustered roots |
| Jenkins-Traub | Sequential algorithm (one root at a time) with superior convergence for difficult polynomials; standard in numerical libraries |
| Companion matrix + eigenvalues | Converts to an eigenvalue problem; robust but \(O(n^3)\) |
| Laguerre’s method | Converges cubically for simple roots; sequential (extracts one root at a time, then deflates) |
| Muller’s method | Uses quadratic interpolation; works for non-polynomial equations too |
graph LR
DK["Durand-Kerner"]
RL["Root Locus"]
GE["Gaussian Elimination"]
RL --> DK
GE -.->|"companion matrix alternative"| DK
| Algorithm | Relationship |
|---|---|
| Root Locus | Primary consumer — calls Durand-Kerner to find characteristic polynomial roots at each gain step |
| Gaussian Elimination | Alternative approach: form the companion matrix and compute eigenvalues (requires a different solver) |
The Discrete Algebraic Riccati Equation arises whenever you need to find an optimal trade-off between state regulation and control effort in a discrete-time linear system. It is the mathematical core of two fundamental algorithms:
The DARE finds a symmetric positive semi-definite matrix \(P\) that encodes the cost-to-go from any state. Once \(P\) is known, the optimal gain is a simple closed-form expression. This library solves the DARE via fixed-point iteration — simple to implement, numerically transparent, and well-suited to the small state dimensions typical in embedded control.
\[P = A^T P A - A^T P B \left(R + B^T P B\right)^{-1} B^T P A + Q\]
where: - \(A \in \mathbb{R}^{n \times n}\) — state transition matrix - \(B \in \mathbb{R}^{n \times m}\) — input matrix - \(Q \in \mathbb{R}^{n \times n}\) — state weighting matrix (\(Q \succeq 0\)) - \(R \in \mathbb{R}^{m \times m}\) — input weighting matrix (\(R \succ 0\)) - \(P \in \mathbb{R}^{n \times n}\) — the unknown (\(P \succeq 0\))
Starting from \(P_0 = Q\):
A unique stabilizing solution exists when: - \((A, B)\) is stabilizable (unstable modes are controllable). - \((A, C)\) is detectable (where \(Q = C^T C\)).
Under these conditions, the iteration converges monotonically: \(P_0 \preceq P_1 \preceq \cdots \preceq P^*\).
\[K = \left(R + B^T P B\right)^{-1} B^T P A\]
| Phase | Time | Space | Notes |
|---|---|---|---|
| Per iteration | \(O(n^3 + n^2 m + m^3)\) | \(O(n^2)\) | Matrix multiplications + one \(m \times m\) linear solve |
| Total solver | \(O(I(n^3 + m^3))\) | \(O(n^2)\) | \(I\) iterations (typically 10–30) |
Why \(O(n^3)\): Each iteration involves \(n \times n\) matrix products (\(A^T P A\)) and an \(m \times m\) linear solve (for \(S^{-1}\)). When \(m \ll n\), the matrix products dominate.
System: \(n=2\), \(m=1\), \(A = \begin{bmatrix}1 & 0.1\\0 & 1\end{bmatrix}\), \(B = \begin{bmatrix}0.005\\0.1\end{bmatrix}\), \(Q = I_2\), \(R = [1]\).
Iteration 0: \(P_0 = Q = I_2\)
Iterations 1–20: \(P\) grows monotonically until convergence. For this \(Q/R\) ratio, \(P^*\) stabilizes in ~15 iterations.
math::Tolerance<T>(), which adapts to both
float and fixed-point types.| Variant | Key Difference |
|---|---|
| Continuous ARE (CARE) | For continuous-time systems; different matrix equation |
| Newton’s method for DARE | Quadratic convergence but requires solving a Sylvester equation per iteration |
| Schur method | Eigendecomposition-based; finds the solution in one pass but requires \(O(n^3)\) eigenvalue computation |
| Doubling algorithm | \(O(\log I)\) convergence via matrix sign function; useful for large systems |
graph LR
DARE["DARE Solver"]
GE["Gaussian Elimination"]
LQR["LQR Controller"]
KF["Kalman Filter"]
GE --> DARE
DARE --> LQR
DARE --> KF
| Algorithm | Relationship |
|---|---|
| Gaussian Elimination | Used at each iteration to solve the \(m \times m\) linear sub-system |
| LQR Controller | Direct consumer — extracts optimal gain \(K\) from the DARE solution matrix \(P\) |
| Kalman Filter | Dual problem — the steady-state Kalman gain is computed by a transposed DARE |
Ordinary differential equations of the form \(\dot{x} = f(x, u, t)\) arise throughout embedded control and dynamics — propagating plant models for prediction, running model-based observers, or performing hardware-in-the-loop simulation on the device itself.
RK4 (the classical fourth-order Runge-Kutta method) solves this problem with a fixed step size, producing deterministic, constant-work-per-tick execution. It is the natural choice for hard-real-time control loops.
Dormand-Prince RK45 is an embedded pair that computes both a 4th- and a 5th-order estimate from the same seven slope evaluations, then uses their difference as a cheap local error gauge. The step-size controller shrinks the step when the estimated error is too large and grows it when the solution is smooth — adapting accuracy to computational budget without user intervention. It is suited for offline simulation or hardware-in-the-loop testing where timing determinism is less critical than accuracy.
Given \(\dot{x} = f(x, u, t)\) with \(x(t_0) = x_0\), advance the state by one step from \(t\) to \(t + h\).
\[ k_1 = f(x_n, u, t_n) \] \[ k_2 = f\!\left(x_n + \tfrac{h}{2}k_1,\ u,\ t_n + \tfrac{h}{2}\right) \] \[ k_3 = f\!\left(x_n + \tfrac{h}{2}k_2,\ u,\ t_n + \tfrac{h}{2}\right) \] \[ k_4 = f\!\left(x_n + h\,k_3,\ u,\ t_n + h\right) \] \[ x_{n+1} = x_n + \frac{h}{6}\left(k_1 + 2k_2 + 2k_3 + k_4\right) \]
The weights \((1, 2, 2, 1)/6\) are derived by matching the Taylor series of the exact solution through fourth order. The local truncation error is \(O(h^5)\); the global error is \(O(h^4)\).
Dormand and Prince (1980) selected a 7-stage Butcher tableau whose 5th-order propagator \(y_5\) and 4th-order embedded propagator \(y_4\) share stages \(k_1, \ldots, k_6\), with \(k_7 = f(y_5, u, t+h)\) added only for the 4th-order correction and for FSAL reuse.
The 5th-order solution used to advance the state:
\[ y_5 = x_n + h\left(\frac{35}{384}k_1 + \frac{500}{1113}k_3 - \frac{125}{192}k_4 + \frac{2187}{6784}k_5 + \frac{11}{84}k_6\right) \]
The 4th-order embedded solution used only for error estimation:
\[ y_4 = x_n + h\left(\frac{5179}{57600}k_1 + \frac{7571}{16695}k_3 - \frac{393}{640}k_4 + \frac{92097}{339200}k_5 + \frac{187}{2100}k_6 + \frac{1}{40}k_7\right) \]
The mixed absolute/relative weighted RMS norm over all \(n_s\) state components:
\[ \text{err} = \sqrt{\frac{1}{n_s} \sum_{i=1}^{n_s} \left(\frac{(y_5 - y_4)_i}{\text{atol} + \text{rtol}\,|x_i|}\right)^2} \]
A step is accepted when \(\text{err} \le 1\). The next step size is:
\[ h_{\text{new}} = h \cdot \text{clamp}\!\left(0.9 \cdot \text{err}^{-1/5},\ 0.2,\ 5\right) \]
clamped further to \([h_{\min}, h_{\max}]\).
The 7th stage \(k_7 = f(y_5, u, t+h)\) equals the first stage of the next accepted step. Caching it reduces each accepted step from 7 to 6 function evaluations.
| Integrator | RHS evaluations per accepted step | State memory |
|---|---|---|
| RK4 (fixed) | 4 (always) | \(O(n_s)\) |
| Dormand-Prince | 6 (with FSAL), 7 on first step | \(O(n_s)\) |
All intermediate stage vectors are stack-allocated. No heap is used. The cost of one step is \(O(s \cdot n_s)\) where \(s\) is the stage count plus the cost of evaluating \(f\).
Scalar decay \(\dot{x} = -x\), \(x(0) = 1\), exact solution \(x(t) = e^{-t}\), \(h = 0.1\):
| Stage | Formula | Value |
|---|---|---|
| \(k_1\) | \(f(1, 0) = -1\) | \(-1\) |
| \(k_2\) | \(f(1 - 0.05, 0.05) = -0.95\) | \(-0.95\) |
| \(k_3\) | \(f(1 - 0.0475, 0.05) = -0.9525\) | \(-0.9525\) |
| \(k_4\) | \(f(1 - 0.09525, 0.1) = -0.90475\) | \(-0.90475\) |
| \(x_1\) | \(1 + (0.1/6)(-1 - 1.9 - 1.905 - 0.90475)\) | \(\approx 0.90484\) |
Exact: \(e^{-0.1} \approx 0.90484\). Agreement to six significant figures — consistent with \(O(h^5)\) local error.
Step; a zero step produces an unchanged state but wastes
evaluations.| Variant | Key Difference |
|---|---|
| Euler (1st order) | One stage; \(O(h)\) global error; useful only for rough prototyping |
| RK4 (this) | Four stages; \(O(h^4)\) global error; standard fixed-step workhorse |
| Dormand-Prince (this) | Seven stages; \(O(h^5)\) propagator with built-in \(O(h^4)\) error estimate |
| Bogacki-Shampine RK23 | Three-stage embedded pair; lower overhead for mildly stiff or smooth problems |
| Adams-Bashforth | Multi-step; reuses past evaluations; efficient but requires startup phase |
| Implicit RK / SDIRK | Solves a nonlinear system at each stage; suitable for stiff problems at the cost of a linear solve per step |
dynamics/) forward in time for prediction horizons in MPC
or trajectory planning.graph LR
RK["RK4 / Dormand-Prince"]
DYN["dynamics/ (Euler-Lagrange, Newton-Euler)"]
EKF["Extended Kalman Filter"]
MPC["MPC Controller"]
C2D["ContinuousToDiscrete"]
DYN --> RK
RK --> EKF
RK --> MPC
C2D -.->|"exact linear alternative"| RK
| Algorithm | Relationship |
|---|---|
dynamics/ models |
Provide the right-hand side \(f(x, u, t)\) that RK integrates |
| Extended Kalman Filter | Uses RK to propagate the state prediction step between measurements |
| MPC Controller | Uses RK to simulate the plant over a prediction horizon |
| ContinuousToDiscrete | Exact matrix-exponential discretization — an alternative for linear, time-invariant systems |
The spectral radius of a square matrix is the largest magnitude among all its eigenvalues. In discrete-time dynamical systems, the spectral radius of the state-transition matrix is the single most important scalar quantity: if it is strictly less than one, every state trajectory converges to zero; if it equals or exceeds one, at least one mode grows without bound or fails to decay. Rather than reporting only a binary stable/unstable flag, exposing the spectral radius and the complementary stability margin (one minus the spectral radius) gives the designer a quantitative handle on how much gain or perturbation the system can tolerate before losing stability.
Given an \(n \times n\) matrix \(A\), the characteristic polynomial is
\[p(\lambda) = \det(\lambda I - A) = \lambda^n + c_{n-1}\lambda^{n-1} + \cdots + c_1\lambda + c_0.\]
The Faddeev–LeVerrier recurrence computes the coefficients \(c_k\) without determinant expansion. Define auxiliary matrices \(M_k\) by
\[M_1 = A, \quad c_{n-1} = -\mathrm{tr}(M_1),\] \[M_k = A\bigl(M_{k-1} + c_{n-k+1}I\bigr), \quad c_{n-k} = -\frac{1}{k}\mathrm{tr}(M_k), \quad k = 2, \ldots, n.\]
This recurrence requires only matrix-matrix multiplication, scalar multiplication of a matrix by the identity, and the trace, making it suitable for static (stack-allocated) computation without a heap.
Given the full set of eigenvalues \(\{\lambda_i\}_{i=1}^{n}\) (roots of the characteristic polynomial),
\[\rho(A) = \max_{i} |\lambda_i|.\]
The roots are found numerically with the Durand–Kerner simultaneous root-finding iteration, which converges for arbitrary polynomials and requires only a fixed-size array of complex iterates.
A matrix \(A\) is Schur-stable (the discrete-time analogue of Hurwitz stability) if and only if \(\rho(A) < 1\). Every eigenvalue then lies strictly inside the complex unit disk, guaranteeing exponential decay of all modes.
\[\delta(A) = 1 - \rho(A).\]
A positive margin indicates Schur stability; \(\delta = 0\) corresponds to marginal stability (an eigenvalue on the unit circle); \(\delta < 0\) signals instability. The margin quantifies how far \(\rho(A)\) is from the stability boundary.
| Case | Time | Space | Notes |
|---|---|---|---|
| Best | \(O(n^3)\) | \(O(n^2)\) | Faddeev–LeVerrier dominates; two \(n\times n\) temporaries on the stack |
| Average | \(O(n^3 + n \cdot I)\) | \(O(n^2 + n)\) | \(I\) Durand–Kerner iterations, typically \(\ll 200\) |
| Worst | \(O(n^3 + n \cdot I_{\max})\) | \(O(n^2 + n)\) | Near-repeated roots slow DK convergence |
The Faddeev–LeVerrier step executes \(n\) matrix multiplications each costing \(O(n^3)\), giving \(O(n^4)\) total; for the small dimensions typical of embedded control matrices (\(n \le 6\)) this is negligible.
Consider \(A = \begin{pmatrix} 0 & -1 \\ 1 & 0 \end{pmatrix}\) (90° rotation, \(\rho = 1\)).
Characteristic polynomial: \(\lambda^2 + 0\lambda + 1 = \lambda^2 + 1\). Roots: \(\pm i\), magnitudes both 1. \(\rho = 1\).
A matrix can be mathematically invertible yet catastrophically sensitive to perturbations in its entries or in the right-hand side of a linear system. This sensitivity, called ill-conditioning, is the root cause of accumulated floating-point error in solvers, regression, and Kalman-filter covariance updates. Measuring it before committing to a solve reveals whether the result can be trusted or whether regularisation, pivoting, or reformulation is warranted.
The condition number is the canonical scalar summary of
ill-conditioning. Because it requires the norm of the inverse, it
depends on a linear solver — which is why it lives in the
solvers layer, on top of the math norms,
rather than in math itself.
For an invertible square matrix \(A \in \mathbb{R}^{N \times N}\) and a chosen matrix norm \(\|\cdot\|\):
\[\kappa(A) = \|A\| \cdot \|A^{-1}\|\]
The condition number bounds the relative error amplification in the solution \(\mathbf{x}\) of \(A\mathbf{x} = \mathbf{b}\) due to a perturbation \(\delta\mathbf{b}\):
\[\frac{\|\delta\mathbf{x}\|}{\|\mathbf{x}\|} \le \kappa(A) \cdot \frac{\|\delta\mathbf{b}\|}{\|\mathbf{b}\|}\]
\(\kappa(A) \ge 1\) always; \(\kappa(A) = 1\) only for scalar multiples of orthogonal matrices. A singular matrix has \(\kappa(A) = \infty\). The estimate here uses the 1-norm.
The norm of \(A^{-1}\) is obtained without forming an explicit inverse as a first-class object: solving \(AX = I\) column by column with partial-pivoting Gaussian elimination yields the inverse columns, from which the 1-norm is accumulated. This mirrors the embedded convention of reusing the existing solver rather than adding a dedicated inversion routine.
| Operation | Time | Space | Notes |
|---|---|---|---|
| ConditionNumber | \(O(N^3)\) | \(O(N^2)\) | Dominated by the \(N\)-column solve of \(AX = I\) |
Matrix \(A = \begin{bmatrix}3 & 1 \\ 1 & 2\end{bmatrix}\), 1-norm condition number:
Singular matrices — a row of all zeros makes the system unsolvable. The singularity check fires before entering Gaussian elimination, returning an empty result rather than dividing by zero.
Near-singular matrices — the condition number can be astronomically large without any row being identically zero. Partial-pivoting elimination still completes, and the returned value reflects the ill-conditioning faithfully.
Norm choice — the 1-norm condition number is a practical upper bound on the spectral condition number \(\kappa_2\); it is cheaper by orders of magnitude because it avoids the SVD.
The spectral condition number (\(\kappa_2\), ratio of largest to smallest singular value) is the tightest but requires an SVD. The reciprocal condition number (\(\text{RCOND} = 1/\kappa\)) avoids overflow when \(\kappa\) is very large; LAPACK-style solvers return this form and compare against machine epsilon. For positive-definite systems, Cholesky factorisation supplies the same column-wise solves at half the cost.
Gaussian elimination with partial pivoting
(solvers::GaussianElimination) supplies the column-wise
solves of \(AX = I\). The matrix 1-norm
(math::OneNorm) supplies both factors of the product.
Cholesky decomposition (solvers::CholeskyDecomposition) is
the positive-definite alternative for the inverse solve.
The QR decomposition factors a matrix \(A\) into an orthogonal factor \(Q\) and an upper-triangular factor \(R\). It is the numerically robust foundation for least-squares regression, eigenvalue computation, and square-root filtering. In embedded systems, fitting an overdetermined model (more sensor measurements than unknowns) arises constantly in calibration, system identification, and sensor fusion. Solving the least-squares problem via QR avoids forming the normal equations \(A^\top A x = A^\top b\), whose condition number is the square of \(A\)’s — a critical advantage when working with reduced word lengths.
For an \(m \times n\) matrix \(A\) with \(m \geq n\), the thin QR factorization is
\[A = Q R,\]
where \(Q \in \mathbb{R}^{m \times n}\) has orthonormal columns (\(Q^\top Q = I_n\)) and \(R \in \mathbb{R}^{n \times n}\) is upper triangular.
A Householder reflector is a symmetric, orthogonal matrix of the form
\[H = I - \beta v v^\top, \quad \beta = \frac{2}{\|v\|^2},\]
chosen so that \(H x = \sigma e_1\) for a target vector \(x\), where \(\sigma = -\mathrm{sign}(x_1)\|x\|\). The sign convention \(\sigma = -\mathrm{sign}(x_1)\|x\|\) avoids catastrophic cancellation when computing \(v_1 = x_1 - \sigma\).
Applying \(k\) successive reflectors \(H_k, \ldots, H_1\) to \(A\) triangularizes it:
\[H_n \cdots H_1 A = R \implies A = H_1 \cdots H_n R = QR.\]
The reflectors are stored implicitly: below the diagonal of the working matrix, alongside a scalar \(\beta_k\) per column, consuming only \(O(mn)\) words.
A Givens rotation
\[G(i,j,\theta) = \begin{pmatrix} c & s \\ -s & c \end{pmatrix}, \quad c = \cos\theta,\ s = \sin\theta\]
zeros a single sub-diagonal entry by rotating two rows. When one new row streams in after an existing factorization, \(n\) plane rotations update \(R\) in \(O(n^2)\) flops — far cheaper than re-factorizing the augmented matrix from scratch.
Given the factorization \(A = QR\), the least-squares minimum-norm solution is
\[x = R^{-1} Q^\top b,\]
computed as: (1) multiply \(b \leftarrow Q^\top b\) by applying the stored reflectors in order without forming \(Q\) explicitly, then (2) back-substitute \(R x = (Q^\top b)_{1:n}\).
| Case | Time | Space | Notes |
|---|---|---|---|
| Best | \(O(mn^2)\) | \(O(mn)\) | Householder on a well-conditioned dense \(A\) |
| Average | \(O(2mn^2 - \tfrac{2}{3}n^3)\) | \(O(mn)\) | Standard flop count from Golub & Van Loan |
| Worst | same as average (no pivoting) | \(O(mn)\) | Rank-deficient case detected but not re-ordered |
| Update | \(O(n^2)\) | \(O(n^2)\) | Single Givens row update; no re-factor |
All memory is stack-allocated via std::array; the factor
is stored in-place over the input.
Factor \(A = \begin{pmatrix} 12 & -51 \\ 6 & 167 \\ -4 & 24 \end{pmatrix}\) (first two columns of the Golub & Van Loan example).
Column 0 (\(k=0\)): Extract \(x = (12, 6, -4)^\top\), \(\|x\| = 14\). Choose \(\sigma = -14\) (sign rule), \(v_0 = 12 + 14 = 26\), \(v = (26, 6, -4)^\top / 26\), \(\beta = 2/\|v\|^2\). After applying \(H_0\): \(R_{00} = 14\), zeros below.
Column 1 (\(k=1\)): Repeat on the \(2 \times 1\) trailing sub-column.
The upper-triangular \(R\) then has diagonal magnitudes \(\{14, 175\}\) for this sub-matrix.
ApplyQtranspose avoids this for solves.SingularValueDecomposition (Golub-Kahan) and the symmetric
eigenvalue solver (Jacobi / tridiagonal QR).GaussianElimination’s upper-triangular solve.LinearRegression for ill-conditioned problems.Many embedded control and estimation algorithms reduce to solving a dense linear system \(A x = b\) for a general (non-symmetric) matrix \(A\). LU decomposition with partial pivoting factors \(A\) as \(P A = L U\), where \(P\) is a permutation, \(L\) is unit lower-triangular, and \(U\) is upper-triangular. Factoring \(A\) once and reusing \(L\) and \(U\) turns every subsequent solve into two cheap triangular sweeps — a critical advantage in loops where the same plant or observer matrix must be inverted against many right-hand sides at different time steps.
For an \(n \times n\) matrix \(A\), partial-pivoting LU factorization produces
\[P A = L U,\]
where: - \(P \in \{0,1\}^{n \times n}\) is a permutation matrix encoding row swaps, - \(L \in \mathbb{R}^{n \times n}\) is unit lower-triangular (\(L_{ii} = 1\), \(L_{ij} = 0\) for \(j > i\)), - \(U \in \mathbb{R}^{n \times n}\) is upper-triangular (\(U_{ij} = 0\) for \(i > j\)).
At step \(k\) of the outer loop (\(k = 0, \ldots, n-1\)):
\[A_{ij} \leftarrow A_{ij} - \ell_{ik} A_{kj}, \quad j > k.\]
After \(n - 1\) steps the working matrix holds \(U\) on and above the diagonal and the multipliers \(\ell_{ik}\) below it, packed together in a single \(n \times n\) array.
Given \(P A = L U\) and a right-hand side \(b\), the system \(A x = b\) becomes \(L U x = P b\). Solving in two passes:
\[L y = P b \quad \text{(forward substitution)},\] \[U x = y \quad \text{(back substitution)}.\]
Both passes cost \(O(n^2)\)
operations, and both reuse the shared
math::SolveUnitLowerTriangular /
math::SolveUpperTriangular kernels (from math/TriangularSolve.hpp)
applied directly to the packed factor — the same triangular-solve
building blocks that back QrDecomposition.
The factored form is exposed for reuse by other components:
L(), U(), and P() return the
individual factors (so callers can verify \(P
A = L U\) or build derived quantities), and
IsSingular() reports whether the last
Decompose aborted.
The determinant of \(A\) equals the product of \(U\)’s diagonal entries multiplied by the sign of \(P\):
\[\det(A) = \mathrm{sign}(P) \prod_{k=0}^{n-1} U_{kk}.\]
The sign flips each time rows are swapped; it starts at \(+1\) and is multiplied by \(-1\) per swap.
The inverse is formed by solving \(A X = I\) column by column — \(n\) back-and-forward substitution pairs — at a total cost of \(O(n^3)\).
| Operation | Time | Space | Notes |
|---|---|---|---|
Decompose |
\(O\!\left(\tfrac{2}{3}n^3\right)\) | \(O(n^2)\) | GEPP in-place; multipliers packed with \(U\) |
Solve |
\(O(n^2)\) | \(O(n)\) | Two triangular sweeps; reuses stored factors |
Determinant |
\(O(n)\) | \(O(1)\) | One pass over \(U\)’s diagonal |
Inverse |
\(O(n^3)\) | \(O(n^2)\) | \(n\) column solves; avoid when
Solve suffices |
All storage is stack-allocated; the packed LU matrix and pivot vector are bounded by \(N\).
Factor \(A = \begin{pmatrix} 2 & 1 & -1 \\ -3 & -1 & 2 \\ -2 & 1 & 2 \end{pmatrix}\).
Step \(k = 0\): Largest magnitude in column 0 is \(|-3| = 3\) at row 1. Swap rows 0 and 1. Working matrix: \(\begin{pmatrix} -3 & -1 & 2 \\ 2 & 1 & -1 \\ -2 & 1 & 2 \end{pmatrix}\). Multipliers: \(\ell_{10} = 2/(-3) = -2/3\), \(\ell_{20} = -2/(-3) = 2/3\). After elimination: \(U_{0\cdot} = (-3,-1,2)\); rows 1 and 2 updated.
Step \(k = 1\): Select pivot in column 1 from rows 1 onward. Possibly swap. Compute multiplier \(\ell_{21}\) and update \(U_{2\cdot}\).
Step \(k = 2\): Single diagonal entry remains; \(U_{22}\) is read directly.
The resulting \(L\) has the stored multipliers below the diagonal (with unit diagonal), and \(U\) sits on and above it.
float), the matrix is (numerically)
rank-deficient. The factorization aborts and returns false;
the caller must not invoke Solve or Inverse. A
relative (rather than absolute) tolerance is essential: on a truly
singular matrix the trailing pivot is a rounding-error residual whose
magnitude scales with the matrix entries and varies across
platforms/fast-math settings, so an absolute cutoff would
miss it.Inverse
calls Solve \(n\) times
and is \(O(n^3)\); prefer
Solve for a single right-hand side to avoid the extra \(O(n^3)\) work and the conditioning penalty
of computing the inverse explicitly.CholeskyDecomposition instead — it is roughly twice as fast
and requires no pivoting.GaussianElimination performs the same GEPP steps but
does not retain the factored form, making it a single-shot solver;
LuDecomposition amortises the \(O(n^3)\) cost over multiple
Solve calls.CholeskyDecomposition is a specialised \(O\!\left(\tfrac{1}{3}n^3\right)\)
factorization for symmetric positive-definite matrices — roughly \(2\times\) faster and without the overhead
of pivoting.QrDecomposition is preferred for least-squares
(overdetermined) problems; for square full-rank systems, LU is
faster.ConditionNumber can be estimated cheaply after
factorization by examining the ratio \(\max_k
|U_{kk}| / \min_k |U_{kk}|\).The Sylvester equation \(AX + XB = C\) and its symmetric specialisations — the continuous Lyapunov \(AX + XA^\top = -Q\) and the discrete Lyapunov \(AXA^\top - X = -Q\) — are the central matrix equations of stability analysis, gramian computation, and robust-control synthesis. A positive-definite Lyapunov solution is a formal certificate that the system \(\dot{x} = Ax\) is asymptotically stable; the same equations produce the controllability and observability gramians that measure how effectively a linear system can be driven and observed.
Given matrices \(A \in \mathbb{R}^{N \times N}\), \(B \in \mathbb{R}^{M \times M}\), and \(C \in \mathbb{R}^{N \times M}\), the Sylvester equation is
\[AX + XB = C,\]
with unknown \(X \in \mathbb{R}^{N \times M}\). The equation has a unique solution if and only if \(A\) and \(-B\) share no eigenvalue, equivalently \(\lambda_i(A) + \lambda_j(B) \neq 0\) for all \(i, j\).
Stacking \(X\) column-by-column into \(\mathrm{vec}(X) \in \mathbb{R}^{NM}\) and applying the identities \(\mathrm{vec}(AXI) = (I_M \otimes A)\mathrm{vec}(X)\) and \(\mathrm{vec}(IXB) = (B^\top \otimes I_N)\mathrm{vec}(X)\) converts the matrix equation into the standard linear system
\[\underbrace{(I_M \otimes A + B^\top \otimes I_N)}_{K \in \mathbb{R}^{NM \times NM}} \mathrm{vec}(X) = \mathrm{vec}(C).\]
The \((iN+k, jN+l)\) entry of \(K\) is \(\delta_{ij} A_{kl} + B_{ji} \delta_{kl}\). The system is solved by Gaussian elimination with partial pivoting; a near-zero pivot signals the unsolvable (degenerate eigenvalue) case.
Setting \(B = A^\top\) and \(C = -Q\) reduces the Sylvester equation to the continuous Lyapunov form
\[AX + XA^\top = -Q.\]
When \(A\) is Hurwitz (all eigenvalues in the open left half-plane) and \(Q\) is symmetric positive semi-definite, the unique solution \(X\) is symmetric positive definite.
The discrete counterpart
\[AXA^\top - X = -Q\]
is solved by the Kronecker system \((A \otimes A - I_{N^2})\mathrm{vec}(X) = -\mathrm{vec}(Q)\), with the \((iN+k, jN+l)\) entry of \(A \otimes A - I\) equal to \(A_{ij}A_{kl} - \delta_{ij}\delta_{kl}\). When \(A\) is Schur-stable (\(\rho(A) < 1\)) and \(Q \succeq 0\), the unique solution equals the controllability gramian \(X = \sum_{k=0}^{\infty} A^k Q (A^\top)^k\).
| Case | Time | Space | Notes |
|---|---|---|---|
| \(N{=}M{=}2\) | \(O(64)\) flops | \(O(NM)^2 = 16\) floats for K | 4×4 linear system |
| \(N{=}M{=}3\) | \(O(729)\) flops | 81 floats for K | 9×9 linear system |
| General \(N{=}M\) | \(O(N^6)\) flops | \(O(N^4)\) words | Gaussian elimination on the \(N^2 \times N^2\) Kronecker system |
| Alternative (Bartels–Stewart) | \(O(N^3)\) | \(O(N^2)\) | Requires Schur decomposition; preferred for \(N \geq 6\) |
For the embedded sizes (\(N \leq 5\)) targeted by this library the Kronecker approach is negligibly more expensive than Bartels–Stewart and avoids the full Schur machinery.
Solve \(AX + XB = C\) for the \(2 \times 2\) case \(A = \bigl[\begin{smallmatrix}-3&1\\0&-2\end{smallmatrix}\bigr]\), \(B = \bigl[\begin{smallmatrix}1&2\\0&1\end{smallmatrix}\bigr]\), \(C = I_2\).
Build \(K\) (the \(4 \times 4\) Kronecker matrix):
\[K = \begin{pmatrix}-2&1&0&0\\0&-1&0&0\\2&0&-2&1\\0&2&0&-1\end{pmatrix}.\]
Build \(\mathrm{vec}(C) = (1, 0, 0, 1)^\top\).
Gaussian elimination with partial pivoting produces \(X_{00} = -0.5\), \(X_{10} = 0\), \(X_{01} = -1\), \(X_{11} = -1\).
Verify: \(AX + XB = I_2\). ✓
false and leaves the solution
unchanged.SquareRootKalmanFilter).LuDecomposition solver.Active Disturbance Rejection Control addresses a fundamental tension in feedback design: high-performance control normally requires an accurate plant model, yet accurate models are expensive to identify and degrade with temperature, load, and wear. ADRC resolves this by treating everything beyond a known input gain — unmodeled dynamics, parameter variation, and external disturbances — as a single lumped signal called the total disturbance. An Extended State Observer (ESO) estimates this signal in real time, and the control law subtracts the estimate before issuing the command. What remains behaves like a clean chain of integrators that a simple PD law can regulate with textbook bandwidth.
The practical payoff on embedded hardware is significant: you need only one plant number (\(b_0\), the rough input gain) and two tuning dials. The controller then survives a bad model because any mismatch is absorbed into the disturbance estimate.
An \(n\)-th order SISO plant is written as the canonical integrator chain plus a total-disturbance term \(f\):
\[y^{(n)} = f(t, y, \dot{y}, \ldots, d) + b_0 u\]
where \(f\) captures unmodeled dynamics, nonlinearities, and external loads; \(b_0\) is a nominal input-gain estimate; and \(u\) is the control input.
Augmenting the \(n\) plant states with \(x_{n+1} = f\) yields an \((n+1)\)-dimensional system. The continuous ESO is a Luenberger-type observer driven by the output error:
\[\dot{\hat{x}}_i = \hat{x}_{i+1} + \beta_i (y - \hat{x}_1), \quad i = 1, \ldots, n\] \[\dot{\hat{x}}_{n+1} = \beta_{n+1} (y - \hat{x}_1)\]
with the convention \(\hat{x}_{n+1} = \hat{f}\) and \(\hat{x}_2\) through \(\hat{x}_n\) as derivative estimates.
The forward-Euler discretization used here is:
\[\hat{x}_i[k+1] = \hat{x}_i[k] + T_s \bigl(\beta_i \, e[k] + \hat{x}_{i+1}[k]\bigr), \quad e[k] = y[k] - \hat{x}_1[k]\]
with \(b_0 u[k-1]\) injected into the \((n)\)-th state to drive the highest derivative.
All observer poles are placed at \(-\omega_o\) (Gao’s bandwidth parameterization). The resulting gains follow the binomial expansion of \((\lambda + \omega_o)^{n+1}\):
\[\beta_i = \binom{n+1}{i} \omega_o^i, \quad i = 1, \ldots, n+1\]
All control poles are placed at \(-\omega_c\) via the expansion of \((\lambda + \omega_c)^n\):
\[k_i = \binom{n}{i} \omega_c^i, \quad i = 1, \ldots, n\]
For a second-order plant (\(n = 2\)):
\[\beta = [3\omega_o,\; 3\omega_o^2,\; \omega_o^3], \quad k = [\omega_c^2,\; 2\omega_c]\]
After disturbance estimation the control is:
\[u = \frac{u_0 - \hat{f}}{b_0}, \qquad u_0 = k_1(r - \hat{x}_1) - \sum_{i=2}^{n} k_i \hat{x}_i\]
Substituting into the plant equation and using \(\hat{f} \approx f\) gives the closed-loop residual \(y^{(n)} \approx u_0\), a pure integrator chain under a PD law — independent of the original plant dynamics.
| Case | Time | Space | Notes |
|---|---|---|---|
| Best | \(O(n)\) | \(O(n)\) | Linear sweep over \(n+1\) ESO states |
| Average | \(O(n)\) | \(O(n)\) | Same; gains precomputed at construction |
| Worst | \(O(n)\) | \(O(n)\) | No branching in the hot path |
Gains are computed once at construction from closed-form binomial
formulas in \(O(n)\) time. The
Compute hot path is a pair of \(O(n)\) loops with no dynamic
allocation.
Second-order plant (\(n=2\)), \(\omega_o = 30\), \(\omega_c = 6\), \(b_0 = 1\), \(T_s = 0.001\) s.
Observer gains: \(\beta_1 = 90\),
\(\beta_2 = 2700\), \(\beta_3 = 27000\).
Control gains: \(k_p = 36\), \(k_d = 12\).
At sample \(k\) with state \(\hat{x} = [\hat{y}, \hat{\dot{y}}, \hat{f}]\), measurement \(y[k]\), reference \(r\):
After a transient of roughly \(5/\omega_o \approx 0.17\) s the observer converges; the output tracks \(r\) with bandwidth \(\omega_c\).
ESO peaking. Large initial estimation errors drive high-magnitude corrections, temporarily saturating the actuator. Mitigation: initialize the observer near the first measurement, or schedule \(\omega_o\) upward from a low value during the first few samples.
Observer bandwidth vs. noise. Increasing \(\omega_o\) speeds convergence but amplifies measurement noise because \(\beta_3 = \omega_o^3\) grows cubically. A practical rule of thumb is \(\omega_o \in [3\omega_c, 10\omega_c]\).
\(b_0\) mismatch. The ESO is robust to moderate mismatch (factor of 2–3), but large errors shrink the stability margin. If \(b_0 \gg b_\text{true}\) the effective loop gain drops and response slows; if \(b_0 \ll b_\text{true}\) the loop gain rises and may oscillate.
Euler discretization accuracy. The forward-Euler ESO introduces phase lag proportional to \(\omega_o T_s\). Keeping \(\omega_o T_s \ll 1\) (e.g., \(\omega_o T_s \leq 0.1\)) maintains accuracy; at higher \(\omega_o T_s\) a ZOH or bilinear discretization is preferred.
Integer overflow in gain computation. Binomial
coefficients are computed with integer arithmetic at compile time. For
large orders or very high bandwidths the intermediate product may exceed
std::size_t before the division; keep \(n \leq 5\) in practice.
Nonlinear ESO (NESO). Replace the linear correction \(\beta_i e\) with Han’s fal function to reduce peaking while preserving fast convergence.
Discrete ESO. Exact discretization of the observer (ZOH or pole-matched) improves accuracy when \(\omega_o T_s\) is not small.
Higher-order plants. The template parameter
Order generalizes the same bandwidth-parameterized
structure to \(n > 2\) — gains grow
binomially and the Compute loop extends automatically.
Multi-input / multi-output (MIMO). Each output channel runs an independent ADRC; cross-coupling is absorbed into the respective disturbance estimates.
Variable-structure control systems change their structure depending on the current system state. Sliding Mode Control (SMC) is the most widely used instantiation: the controller switches between two or more structures to drive the state onto a designer-specified manifold — the sliding surface — and keep it there. Once constrained to the surface, the closed-loop dynamics are governed entirely by the surface geometry, independent of the plant model or matched disturbances. This structural robustness makes SMC a preferred choice for motor drives, DC-DC power converters, and any embedded plant whose parameters drift or are poorly known.
The controller operates on a discrete-time linear plant
\[x_{k+1} = A x_k + B u_k, \quad x \in \mathbb{R}^n,\; u \in \mathbb{R}^m\]
where \(A \in \mathbb{R}^{n \times n}\) and \(B \in \mathbb{R}^{n \times m}\).
A linear sliding surface is defined by
\[s(x) = S x, \quad S \in \mathbb{R}^{m \times n}\]
The sliding manifold \(\{x : s(x) = 0\}\) is an \((n-m)\)-dimensional subspace. The matrix \(S\) is designed so the reduced-order dynamics on the manifold are stable and meet the desired closed-loop poles.
The equivalent control \(u_{eq}\) is the unique input that holds the state on \(s = 0\) (i.e., \(\dot{s} = 0\)) for the nominal plant:
\[u_{eq} = -(S B)^{-1} S A x\]
This exists if and only if \(S B\) is nonsingular, which is the relative-degree-one condition: each input channel must directly influence its corresponding sliding variable.
The switching term adds a robust push toward the surface:
\[u_{sw} = (S B)^{-1} K \mathrm{sat}(s/\phi)\]
where \(K \in \mathbb{R}^{m}\) is the per-channel switching gain and \(\phi > 0\) is the boundary-layer thickness. The saturation function
\[\mathrm{sat}(\sigma) = \begin{cases} \sigma & |\sigma| \le 1 \\ \mathrm{sign}(\sigma) & |\sigma| > 1 \end{cases}\]
replaces the discontinuous \(\mathrm{sign}(s)\) of ideal SMC with a continuous ramp inside \(|s| \le \phi\), eliminating infinite-bandwidth chattering while maintaining the reaching property.
\[u = u_{eq} - u_{sw} = -(S B)^{-1}\bigl[S A x + K\,\mathrm{sat}(s/\phi)\bigr]\]
The Lyapunov function \(V = \tfrac{1}{2} s^\top s\) satisfies \(\dot{V} < 0\) outside the boundary layer when \(K_i > |d_i|\) for each matched-disturbance channel \(d_i\). This guarantees finite-time arrival at \(|s| \le \phi\).
On the surface \(s = 0\), the state evolves according to the \((n-m)\)-dimensional reduced-order system. For a single-input system with \(S = [c_1, \ldots, c_{n-1}, 1]\), the sliding pole is determined by the characteristic polynomial of the first \((n-1)\) rows under the surface constraint.
| Operation | Time | Space | Notes |
|---|---|---|---|
| Construction | \(O(m^3 + n m)\) | \(O(nm + m^2)\) | \((SB)^{-1}\) inversion once |
| ComputeControl | \(O(n^2 + m^2)\) | \(O(1)\) extra | \(S A x\) dominates for large \(n\) |
| Surface | \(O(nm)\) | \(O(1)\) extra | monitoring only |
All data is stored in fixed-size arrays; no heap allocation at any point.
Consider a double-integrator plant (\(n=2\), \(m=1\)):
\[A = \begin{bmatrix}0 & 1\\0 & 0\end{bmatrix}, \quad B = \begin{bmatrix}0\\1\end{bmatrix}\]
with surface \(S = [1\; 1]\), gain \(K = 2\), boundary layer \(\phi = 0.05\).
sat function is semantically identical to the saturation
block already in the library.Real plants never match their nominal models. External loads, friction, actuator nonlinearities, and parameter drift inject unmodeled energy into the loop. A Disturbance Observer (DOB) lumps all of these effects into a single equivalent disturbance signal, estimates it online, and subtracts it from the control input so the plant behaves as if it were the clean nominal model.
The key insight is that the DOB wraps around any existing controller without redesigning it. An engineer who has already tuned a PID or LQR for the nominal plant can bolt on a DOB and gain strong disturbance rejection without revisiting the nominal design. This makes DOBs especially attractive for embedded motion controllers — motor drives, precision stages, robotic joints — where the plant is moderately well-known but subject to load variations the nominal model ignores.
Let the true discrete-time plant be \(P(z)\) and the nominal model be \(P_n(z)\). The control input seen by the true plant is \(u_a = u + d\), where \(u\) is the commanded input and \(d\) is the lumped equivalent disturbance that captures model mismatch, external loads, and friction. The plant output is
\[y = P(z)\, u_a = P(z)(u + d).\]
If \(P_n^{-1}(z)\) is applied to \(y\), it reconstructs the effective input that the nominal plant would have needed to produce that output:
\[P_n^{-1}(z)\, y \approx u + d \quad \text{(if } P \approx P_n\text{)}.\]
Subtracting the actual commanded input \(u\) isolates the disturbance:
\[\hat{d} = P_n^{-1}(z)\, y - u.\]
The plant inverse \(P_n^{-1}(z)\) is generally improper (more zeros than poles) and amplifies high-frequency measurement noise. A low-pass Q-filter \(Q(z)\) is cascaded to make the combination \(Q(z)\,P_n^{-1}(z)\) proper and bandwidth-limited:
\[\hat{d} = Q(z)\,P_n^{-1}(z)\, y - Q(z)\, u.\]
The filter \(Q(z)\) must have relative degree at least equal to the relative degree of \(P_n(z)\) so the realization does not differentiate. Unity DC gain, \(Q(1) = 1\), is required for complete rejection of constant (step) disturbances.
The DOB corrects the nominal controller output \(c\) by subtracting the estimate:
\[u = c - \hat{d}.\]
The closed-loop system then sees an effective plant of \(P_n(z)\) inside the Q-filter bandwidth — the actual mismatch and disturbances are cancelled — and approaches the uncorrected nominal plant behaviour outside the bandwidth.
Let \(L(z) = Q(z)\,P_n^{-1}(z)\,P(z)\). The closed-loop sensitivity from disturbance \(d\) to output \(y\) is
\[S_d(z) = \frac{P(z)(1 - Q(z))}{1 + P(z)C(z)(1 - Q(z))}.\]
Inside the Q-filter passband (\(Q \approx 1\)): \(S_d \approx 0\) — the disturbance is rejected. Outside the passband (\(Q \approx 0\)): \(S_d\) equals the nominal sensitivity — the DOB is transparent.
Robust stability requires the complementary sensitivity of the inner DOB loop to satisfy
\[\left|Q(e^{j\omega})\,\Delta_m(e^{j\omega})\right| < 1 \quad \forall\, \omega,\]
where \(\Delta_m = (P - P_n)/P_n\) is the relative model uncertainty. Widening \(Q\) improves disturbance rejection but shrinks the robust-stability margin — this is the fundamental DOB trade-off.
| Operation | Time | Space | Notes |
|---|---|---|---|
| Construct | \(O(N \cdot S^2)\) | \(O(S^2 + N)\) | DC gain simulation, \(S\) = StateSize, \(N\) = 512 |
| Compute | \(O(N_{\rm in})\) | \(O(1)\) extra | Per-channel biquad filter pair |
| Reset | \(O(N_{\rm in})\) | \(O(1)\) extra | Clears filter states |
All storage is fixed-size; no heap allocation occurs at any point in the lifecycle.
Consider a first-order discrete plant (\(n=1\), \(m=p=1\)) with \(a=0.9\), \(b=0.1\), \(c=1\), DC gain \(= b/(1-a) = 1\), and a second-order Butterworth Q-filter at 20 Hz (sample rate 1 kHz).
Steady-state with constant disturbance \(d = 0.5\), nominal command \(c = 0\):
A window function is a mathematical taper applied to a finite-length signal before spectral analysis. When we extract a segment of \(N\) samples from a continuous signal, we implicitly multiply by a rectangular window — a hard cut that introduces sharp discontinuities at the segment boundaries. In the frequency domain these discontinuities spread energy across all bins, an artefact called spectral leakage.
Window functions taper the segment smoothly toward zero at both ends, trading a small amount of frequency resolution (wider main lobe) for dramatically reduced leakage (lower side lobes). Choosing the right window is a fundamental step in every FFT-based pipeline.
A symmetric window of length \(N\) is a sequence \(w[n],\; n = 0, 1, \ldots, N-1\), designed so that \(w[0] \approx w[N-1] \approx 0\) (except for the rectangular case).
| Window | Definition | Main-Lobe Width | Peak Side Lobe |
|---|---|---|---|
| Rectangular | \(w[n] = 1\) | \(2/N\) | \(-13\) dB |
| Hamming | \(w[n] = 0.54 - 0.46\cos\!\left(\frac{2\pi n}{N-1}\right)\) | \(4/N\) | \(-43\) dB |
| Hann | \(w[n] = 0.5\left(1 - \cos\!\left(\frac{2\pi n}{N-1}\right)\right)\) | \(4/N\) | \(-32\) dB |
| Blackman | \(w[n] = 0.42 - 0.5\cos\!\left(\frac{2\pi n}{N-1}\right) + 0.08\cos\!\left(\frac{4\pi n}{N-1}\right)\) | \(6/N\) | \(-58\) dB |
In the frequency domain, windowing is convolution with the window’s Fourier transform \(W(f)\). Each spectral line of the signal is smeared by \(W(f)\). A narrower main lobe preserves resolution; lower side lobes suppress leakage.
The coherent gain \(CG\) of a window normalizes the amplitude after windowing:
\[CG = \frac{1}{N}\sum_{n=0}^{N-1} w[n]\]
For amplitude-accurate measurements, divide the windowed FFT output by \(CG\).
The processing gain \(PG\) compares the noise bandwidth of the window to the rectangular window:
\[PG = \frac{\left(\sum w[n]\right)^2}{N \sum w[n]^2}\]
| Window | Processing Gain |
|---|---|
| Rectangular | 1.00 |
| Hamming | 0.73 |
| Hann | 0.67 |
| Blackman | 0.57 |
| Operation | Time | Space |
|---|---|---|
| Generate \(N\) window coefficients | \(O(N)\) | \(O(N)\) |
| Apply window (element-wise multiply) | \(O(N)\) | \(O(1)\) extra |
| Pre-computed lookup | \(O(1)\) per sample | \(O(N)\) |
Window generation is dominated by trigonometric evaluations (\(\cos\)). For repeated use at a fixed \(N\), pre-compute and store the coefficients in a lookup table.
Scenario: Apply a Hann window to \(N = 8\) samples of a pure tone at bin 2.5 (non-integer frequency → leakage expected).
Step 1 — Generate the Hann window
| \(n\) | \(w[n] = 0.5(1 - \cos(2\pi n / 7))\) |
|---|---|
| 0 | 0.000 |
| 1 | 0.188 |
| 2 | 0.611 |
| 3 | 0.950 |
| 4 | 0.950 |
| 5 | 0.611 |
| 6 | 0.188 |
| 7 | 0.000 |
Step 2 — Element-wise multiply
\[x_w[n] = x[n] \cdot w[n]\]
The signal tapers to zero at both ends, removing the hard edges.
Step 3 — FFT of windowed signal
Compared to the un-windowed (rectangular) FFT: - The peak at bin 2.5 is slightly broader (main lobe widens from 2 to 4 bins). - The leakage floor drops from \(-13\) dB to \(-32\) dB.
graph LR
subgraph Before Windowing
R["Rectangular window<br/>Sharp edges → leakage"]
end
subgraph After Windowing
H["Hann window<br/>Smooth taper → low side lobes"]
end
R --> |"apply Hann"| H
H --> FFT["FFT → cleaner spectrum"]
| Variant | Key Difference |
|---|---|
| Kaiser window | Parameterized by \(\beta\); allows continuous trade-off between main-lobe width and side-lobe level |
| Flat-top window | Near-zero amplitude error at the cost of very wide main lobe; used for calibration |
| Gaussian window | Smooth, no side-lobe discontinuities; parameterized by \(\sigma\) |
| Dolph–Chebyshev | Equiripple side lobes at a prescribed level; optimal for a given main-lobe width |
| DPSS (Slepian) | Maximum energy concentration in a given bandwidth; used in multi-taper spectral estimation |
graph TD
W["Window Functions"]
FFT["Fast Fourier Transform"]
PSD["Power Density Spectrum"]
DCT["Discrete Cosine Transform"]
W --> FFT
W --> PSD
W --> DCT
| Algorithm | Relationship |
|---|---|
| FFT | Window is applied before the FFT to reduce spectral leakage |
| Power Density Spectrum | Built-in windowing per segment in Welch’s method |
| DCT | DCT’s implicit symmetry provides partial leakage suppression, but windowing still helps |