Skip to content

Commit 4f038f1

Browse files
Merge branch 'main' into feature/add-c2d
2 parents e698e2f + 384c739 commit 4f038f1

12 files changed

Lines changed: 429 additions & 197 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Refer to the documentation to quickly integrate and utilize the library's signal
1717
| Category | Description |
1818
|--------------------------------------------------------------------|----------------------------------------------------------------------|
1919
| [Analysis](doc/analysis/README.md) | FFT, Real-Input FFT (RFFT), Power Spectral Density, DCT, Window Functions, Signal Detectors, Convolution & Correlation, Goertzel Algorithm, Decibels |
20-
| [Control Analysis](doc/control_analysis/README.md) | Frequency Response, Root Locus, Controllability/Observability Matrices & Gramians, Continuous-to-Discrete |
20+
| [Control Analysis](doc/control_analysis/README.md) | Frequency Response, Root Locus, Controllability/Observability Matrices & Gramians, Continuous-to-Discrete, Transfer Function ↔ State Space |
2121
| [Controllers](doc/controllers/README.md) | Bang-Bang/Hysteresis, PID, LQR, LQI (Integral/Servo State Feedback), MPC, Saturation, Rate Limiter, Slew-Limited Saturation, Feedforward/2-DOF, Gain-Scheduled Controller, Lead-Lag Compensator, Luenberger Observer |
2222
| [Dynamics](doc/dynamics/README.md) | Euler-Lagrange, Newton-Euler, Recursive Newton-Euler, ABA |
2323
| [Estimators](doc/estimators/README.md) | Linear Regression, Polynomial Fitting, Yule-Walker (offline), Recursive Least Squares, LMS / NLMS Adaptive Filter (online), Consistency Metrics / NEES / NIS |

ROADMAP.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ Difficulty legend:
2828
| # | Component | Target module | Difficulty |
2929
|----|------------------------------------------------------|---------------------------|------------|
3030
| 30 | Continuous → discrete conversion (`c2d`) | `math` | ★★★★☆ |
31-
| 32 | Transfer-function ↔ state-space conversion | `control_analysis` | ★★★★☆ |
3231
| 33 | Madgwick / Mahony AHRS | `filters/active` | ★★★★☆ |
3332
| 34 | Sliding Mode Control (SMC) | `robust_control` (new) | ★★★★☆ |
3433
| 35 | Disturbance Observer (DOB) | `robust_control` (new) | ★★★★☆ |
@@ -204,12 +203,6 @@ the library does not yet expose. Detailed below under
204203
- **Algorithm / paper:** R. Bartels, G. Stewart, "Solution of the Matrix Equation AX + XB = C," *Comm. ACM*, 15(9), 1972.
205204
- **Reuses:** `math::Matrix`, item 27 (Schur/QR building blocks).
206205

207-
### 32. Transfer-function ↔ state-space conversion *(float-first)*
208-
- **What:** Convert between transfer-function coefficients and controllable/observable canonical state-space forms.
209-
- **Embedded value:** Bridges classical (frequency-domain) and modern (state-space) design tools within the library.
210-
- **Algorithm / paper:** T. Kailath, *Linear Systems* (1980), canonical realizations.
211-
- **Reuses:** `math::LinearTimeInvariant`, `control_analysis`, `math::Matrix`.
212-
213206
### 33. Madgwick / Mahony AHRS *(float-first)*
214207
- **What:** Quaternion-based orientation filter fusing gyro + accel (+ mag): Madgwick's gradient-descent correction or Mahony's passive complementary filter on SO(3).
215208
- **Embedded value:** The de-facto attitude estimator for drones, robots, and wearables — cheaper and more robust than a full quaternion EKF.

doc/control_analysis/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ Control system analysis tools for characterizing dynamic systems and feedback lo
1010
| [Root Locus](RootLocus.md) | Closed-loop pole trajectory analysis as a function of loop gain |
1111
| [Controllability/Observability](ControllabilityObservability.md) | Structural reachability and detectability analysis with Gramians |
1212
| [Continuous-to-Discrete](ContinuousToDiscrete.md) | Convert continuous (A,B,C,D) state-space to discrete via ZOH (Van Loan), Tustin/bilinear, and forward/backward Euler |
13+
| [Transfer Function ↔ State Space](TransferFunctionStateSpace.md) | Conversion between transfer-function polynomials and canonical state-space forms |
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Transfer-Function ↔ State-Space Conversion
2+
3+
## Overview & Motivation
4+
5+
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.
6+
7+
## Mathematical Theory
8+
9+
### Transfer Function
10+
11+
A SISO rational transfer function is
12+
13+
$$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}$$
14+
15+
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.
16+
17+
### Controllable Canonical Form
18+
19+
The monic denominator maps directly to the companion (controllable canonical) matrix
20+
21+
$$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}$$
22+
23+
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.
24+
25+
### Observable Canonical Form
26+
27+
The observable canonical form is the algebraic dual of the controllable form:
28+
29+
$$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}$$
30+
31+
Both realisations represent the same input-output map and share identical transfer functions.
32+
33+
### State-Space to Transfer Function: Faddeev–Le Verrier
34+
35+
Given $(A, B, C, D)$, the transfer function is recovered via
36+
37+
$$H(s) = C\,(sI - A)^{-1} B + D = \frac{C\,\text{adj}(sI - A)\,B + D\,\det(sI - A)}{\det(sI - A)}$$
38+
39+
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:
40+
41+
$$M_0 = I, \quad c_k = -\frac{1}{k}\operatorname{tr}(A M_{k-1}), \quad M_k = A M_{k-1} + c_k I$$
42+
43+
The numerator coefficient for degree $n - k$ is $C M_k B$.
44+
45+
## Complexity Analysis
46+
47+
| Operation | Time | Space | Notes |
48+
|---------------------------|----------|----------|----------------------------------------|
49+
| `ToControllableCanonical` | $O(n)$ | $O(n^2)$ | Direct coefficient placement |
50+
| `ToObservableCanonical` | $O(n^2)$ | $O(n^2)$ | One transpose after CCF |
51+
| `ToTransferFunction` | $O(n^4)$ | $O(n^2)$ | $n$ steps, each $O(n^3)$ matrix-matrix |
52+
53+
Memory is $O(n^2)$ for $A$ and $O(n)$ for coefficient arrays; all allocations are `std::array` on the stack.
54+
55+
## Step-by-Step Walkthrough
56+
57+
$H(s) = \dfrac{s + 2}{s^2 + 3s + 2}$, so $n = 2$, denominator $[1, 3, 2]$, numerator $[0, 1, 2]$.
58+
59+
**Monic normalisation:** already monic; $\hat{a}_1 = 3$, $\hat{a}_2 = 2$.
60+
61+
**Feed-through split:** $b_0 = 0$, so $D = 0$ and $\hat{b}' = [0, 1, 2]$.
62+
63+
**Companion matrix:**
64+
65+
$$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}$$
66+
67+
**Observable form:**
68+
69+
$$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}$$
70+
71+
**Round-trip verification via Le Verrier ($n = 2$):**
72+
73+
- $M_0 = I$; $c_1 = -\tfrac{1}{1}\operatorname{tr}(AM_0) = -\operatorname{tr}(A) = 3$; $M_1 = A + 3I = \begin{bmatrix}3 & 1 \\ -2 & 0\end{bmatrix}$.
74+
- $c_2 = -\tfrac{1}{2}\operatorname{tr}(AM_1) = -\tfrac{1}{2}\operatorname{tr}\!\begin{bmatrix}-2 & 0 \\ -6 & -2\end{bmatrix} = 2$.
75+
- Numerator coefficients: $CM_1B = [2,1]\begin{bmatrix}1\\0\end{bmatrix} = 2$; $CM_2B = CM_0B = 0$.
76+
77+
Result: $H(s) = \dfrac{s + 2}{s^2 + 3s + 2}$. Identical to the original.
78+
79+
## Pitfalls & Edge Cases
80+
81+
- **Near-zero leading denominator coefficient:** the normalisation step divides by $a_0$; a guard is required to avoid division by zero.
82+
- **Non-minimal realisation:** if numerator and denominator share a common factor (pole-zero cancellation), the realisation is valid but is not minimal — it is either uncontrollable or unobservable. The conversion does not detect or cancel common factors.
83+
- **Le Verrier conditioning:** the recursion accumulates floating-point errors in $O(n^4)$ operations. For large $n$ the recovered transfer function coefficients degrade. Keep system orders to the small values typical on an MCU.
84+
- **Proper vs strictly proper:** when $\deg(\text{num}) < \deg(\text{den})$ the feed-through term $D = 0$ and the split step is a no-op. No special branch is required; the leading numerator coefficient being zero makes $D = 0$ automatically.
85+
86+
## Variants & Generalizations
87+
88+
- **Phase-variable form:** an alternative state numbering; identical to controllable canonical form up to index reversal.
89+
- **Modal canonical form:** diagonalises $A$; numerically sensitive but decouples modes for analysis.
90+
- **Balanced realisation:** minimises the condition number jointly; requires Gramian computation and similarity transformation.
91+
- **Continuous-time vs discrete-time:** the companion structure is identical; only the interpretation of $s$ vs $z$ changes.
92+
93+
## Applications
94+
95+
- Bridging frequency-domain compensator design to the `LinearTimeInvariant` runtime for embedded execution.
96+
- Computing frequency response or root locus from a state-space plant model.
97+
- Initialising Kalman filter or LQR designs from a transfer-function spec.
98+
- Verifying a state-space model by recovering and inspecting its transfer function.
99+
100+
## Connections to Other Algorithms
101+
102+
- **`LinearTimeInvariant`:** the target realisation type; canonical forms drop directly into its `Step`/`Output` interface.
103+
- **`ControllabilityObservability`:** a canonical realisation is minimal if and only if it is both controllable and observable; use these checks after conversion.
104+
- **`FrequencyResponse` / `RootLocus`:** classical analyses that accept transfer-function coefficients; feed the recovered transfer function from `ToTransferFunction` directly.
105+
- **`DurandKerner`:** factors the denominator polynomial into its roots (poles); combine with this converter for pole-zero plots.
106+
107+
## References & Further Reading
108+
109+
- T. Kailath, *Linear Systems*, Prentice-Hall, 1980 — canonical realisations and the state-space / transfer-function correspondence (Ch. 6).
110+
- C.-T. Chen, *Linear System Theory and Design*, 4th ed., Oxford University Press, 2013.
111+
- K. J. Åström and R. M. Murray, *Feedback Systems*, Princeton University Press, 2008, Ch. 9.

numerical/control_analysis/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ target_sources(numerical.control_analysis PRIVATE
1616
ControllabilityObservability.hpp
1717
FrequencyResponse.hpp
1818
RootLocus.hpp
19+
TransferFunctionStateSpace.hpp
1920
)
2021

2122
numerical_add_coverage_sources(numerical.control_analysis
2223
ContinuousToDiscrete.cpp
2324
ControllabilityObservability.cpp
2425
FrequencyResponse.cpp
2526
RootLocus.cpp
27+
TransferFunctionStateSpace.cpp
2628
)
2729

2830
add_subdirectory(test)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#include "numerical/control_analysis/TransferFunctionStateSpace.hpp"
2+
3+
namespace control_analysis
4+
{
5+
template class TransferFunctionStateSpace<float, 2>;
6+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
#pragma once
2+
3+
#if defined(__GNUC__) || defined(__clang__)
4+
#pragma GCC optimize("O3", "fast-math")
5+
#endif
6+
7+
#include "numerical/math/CompilerOptimizations.hpp"
8+
#include "numerical/math/LinearTimeInvariant.hpp"
9+
#include "numerical/math/Matrix.hpp"
10+
#include <array>
11+
#include <cstddef>
12+
#include <type_traits>
13+
14+
namespace control_analysis
15+
{
16+
template<typename T, std::size_t n>
17+
struct TransferFunction
18+
{
19+
static_assert(std::is_floating_point_v<T>, "TransferFunction supports floating-point types");
20+
21+
std::array<T, n + 1> numerator{};
22+
std::array<T, n + 1> denominator{};
23+
};
24+
25+
template<typename T, std::size_t n>
26+
class TransferFunctionStateSpace
27+
{
28+
static_assert(std::is_floating_point_v<T>, "TransferFunctionStateSpace supports floating-point types");
29+
30+
public:
31+
using Realization = math::LinearTimeInvariant<T, n, 1, 1>;
32+
using TF = TransferFunction<T, n>;
33+
using StateMatrix = math::Matrix<T, n, n>;
34+
using InputMatrix = math::Matrix<T, n, 1>;
35+
using OutputMatrix = math::Matrix<T, 1, n>;
36+
using FeedMatrix = math::Matrix<T, 1, 1>;
37+
38+
OPTIMIZE_FOR_SPEED static Realization ToControllableCanonical(const TF& tf);
39+
OPTIMIZE_FOR_SPEED static Realization ToObservableCanonical(const TF& tf);
40+
OPTIMIZE_FOR_SPEED static TF ToTransferFunction(const Realization& sys);
41+
42+
private:
43+
static std::array<T, n + 1> NormalizeDenominator(const std::array<T, n + 1>& den);
44+
static std::pair<T, std::array<T, n + 1>> ProperSplit(
45+
const std::array<T, n + 1>& num, const std::array<T, n + 1>& monicDen);
46+
static StateMatrix CharacteristicPolynomialCoefficients(const Realization& sys);
47+
static std::array<T, n + 1> LeVerrierNumerator(
48+
const Realization& sys, const std::array<T, n + 1>& charPoly);
49+
};
50+
51+
//// Implementation ////
52+
53+
template<typename T, std::size_t n>
54+
std::array<T, n + 1> TransferFunctionStateSpace<T, n>::NormalizeDenominator(
55+
const std::array<T, n + 1>& den)
56+
{
57+
std::array<T, n + 1> result{};
58+
T leading{ den[0] };
59+
if (leading == T(0))
60+
leading = T(1);
61+
for (std::size_t i{ 0 }; i <= n; ++i)
62+
result[i] = den[i] / leading;
63+
return result;
64+
}
65+
66+
template<typename T, std::size_t n>
67+
std::pair<T, std::array<T, n + 1>> TransferFunctionStateSpace<T, n>::ProperSplit(
68+
const std::array<T, n + 1>& num, const std::array<T, n + 1>& monicDen)
69+
{
70+
T leading{ num[0] };
71+
std::array<T, n + 1> remainder{};
72+
for (std::size_t i{ 0 }; i <= n; ++i)
73+
remainder[i] = num[i] - leading * monicDen[i];
74+
return { leading, remainder };
75+
}
76+
77+
template<typename T, std::size_t n>
78+
OPTIMIZE_FOR_SPEED typename TransferFunctionStateSpace<T, n>::Realization
79+
TransferFunctionStateSpace<T, n>::ToControllableCanonical(const TF& tf)
80+
{
81+
T leading{ tf.denominator[0] };
82+
if (leading == T(0))
83+
leading = T(1);
84+
auto monic{ NormalizeDenominator(tf.denominator) };
85+
std::array<T, n + 1> scaledNum{};
86+
for (std::size_t i{ 0 }; i <= n; ++i)
87+
scaledNum[i] = tf.numerator[i] / leading;
88+
auto [d, bhat]{ ProperSplit(scaledNum, monic) };
89+
90+
StateMatrix A{};
91+
for (std::size_t i{ 0 }; i < n - 1; ++i)
92+
A.at(i, i + 1) = T(1);
93+
for (std::size_t j{ 0 }; j < n; ++j)
94+
A.at(n - 1, j) = -monic[n - j];
95+
96+
InputMatrix B{};
97+
B.at(n - 1, 0) = T(1);
98+
99+
OutputMatrix C{};
100+
for (std::size_t j{ 0 }; j < n; ++j)
101+
C.at(0, j) = bhat[n - j];
102+
103+
FeedMatrix D{};
104+
D.at(0, 0) = d;
105+
106+
Realization result{};
107+
result.A = A;
108+
result.B = B;
109+
result.C = C;
110+
result.D = D;
111+
return result;
112+
}
113+
114+
template<typename T, std::size_t n>
115+
OPTIMIZE_FOR_SPEED typename TransferFunctionStateSpace<T, n>::Realization
116+
TransferFunctionStateSpace<T, n>::ToObservableCanonical(const TF& tf)
117+
{
118+
auto ccf{ ToControllableCanonical(tf) };
119+
Realization result{};
120+
result.A = ccf.A.Transpose();
121+
result.B = ccf.C.Transpose();
122+
result.C = ccf.B.Transpose();
123+
result.D = ccf.D;
124+
return result;
125+
}
126+
127+
template<typename T, std::size_t n>
128+
OPTIMIZE_FOR_SPEED typename TransferFunctionStateSpace<T, n>::TF
129+
TransferFunctionStateSpace<T, n>::ToTransferFunction(const Realization& sys)
130+
{
131+
using SqMat = math::Matrix<T, n, n>;
132+
133+
std::array<T, n + 1> charPoly{};
134+
charPoly[0] = T(1);
135+
136+
SqMat M{ SqMat::Identity() };
137+
138+
for (std::size_t k{ 1 }; k <= n; ++k)
139+
{
140+
M = sys.A * M;
141+
charPoly[k] = -M.Trace() / static_cast<T>(k);
142+
for (std::size_t i{ 0 }; i < n; ++i)
143+
M.at(i, i) += charPoly[k];
144+
}
145+
146+
std::array<T, n + 1> numCoeffs{};
147+
numCoeffs[0] = sys.D.at(0, 0);
148+
149+
SqMat N{ SqMat::Identity() };
150+
for (std::size_t k{ 1 }; k <= n; ++k)
151+
{
152+
auto CB{ sys.C * N * sys.B };
153+
numCoeffs[k] = CB.at(0, 0);
154+
N = sys.A * N;
155+
for (std::size_t i{ 0 }; i < n; ++i)
156+
N.at(i, i) += charPoly[k];
157+
}
158+
159+
TF result{};
160+
result.numerator = numCoeffs;
161+
result.denominator = charPoly;
162+
return result;
163+
}
164+
165+
#ifdef NUMERICAL_TOOLBOX_COVERAGE_BUILD
166+
extern template class TransferFunctionStateSpace<float, 2>;
167+
#endif
168+
}

numerical/control_analysis/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ target_sources(numerical.control_analysis_test PRIVATE
1313
TestControllabilityObservability.cpp
1414
TestFrequencyResponse.cpp
1515
TestRootLocus.cpp
16+
TestTransferFunctionStateSpace.cpp
1617
)

0 commit comments

Comments
 (0)