Skip to content

Commit 381a4ec

Browse files
Merge branch 'main' into feature/add-luenberger-observer
2 parents f4edb8e + a817996 commit 381a4ec

11 files changed

Lines changed: 305 additions & 170 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Refer to the documentation to quickly integrate and utilize the library's signal
1818
|--------------------------------------------------------------------|----------------------------------------------------------------------|
1919
| [Analysis](doc/analysis/README.md) | FFT, Power Spectral Density, DCT, Window Functions, Signal Detectors, Convolution & Correlation, Goertzel Algorithm |
2020
| [Control Analysis](doc/control_analysis/README.md) | Frequency Response, Root Locus |
21-
| [Controllers](doc/controllers/README.md) | Bang-Bang/Hysteresis, PID, LQR, MPC, Saturation, Rate Limiter, Slew-Limited Saturation, Feedforward/2-DOF, Gain-Scheduled Controller, Luenberger Observer |
21+
| [Controllers](doc/controllers/README.md) | Bang-Bang/Hysteresis, PID, LQR, 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 (online) |
2424
| [Filters](doc/filters/README.md) | Kalman, Extended Kalman, Unscented Kalman, Alpha-Beta/Alpha-Beta-Gamma, FIR, IIR, Exponential Moving Average, Moving Average, Complementary, Median Filter, CIC (Cascaded Integrator-Comb), Notch/Comb Filter |

ROADMAP.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ Difficulty legend:
2828
| # | Component | Target module | Difficulty |
2929
|----|------------------------------------------------------|---------------------------|------------|
3030
| 15 | Biquad / Second-Order-Section cascade | `filters/passive` | ★★★☆☆ |
31-
| 17 | Lead-lag compensator | `controllers` | ★★★☆☆ |
3231
| 20 | Integral / servo state feedback (LQI) | `controllers` | ★★★☆☆ |
3332
| 21 | LMS / NLMS adaptive filter | `estimators/online` | ★★★☆☆ |
3433
| 22 | Savitzky-Golay filter | `filters/passive` | ★★★☆☆ |
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Lead-Lag Compensator
2+
3+
## Overview & Motivation
4+
5+
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.
6+
7+
## Mathematical Theory
8+
9+
### Continuous-Time Transfer Function
10+
11+
The compensator is defined in the Laplace domain as:
12+
13+
$$C(s) = K \cdot \frac{s + z}{s + p}$$
14+
15+
where $K$ is the overall gain, $z$ is the zero frequency (rad/s), and $p$ is the pole frequency (rad/s).
16+
17+
The DC gain is $C(0) = K \cdot z / p$.
18+
19+
- **Lead network** ($z < p$): the zero sits below the pole, so phase rises at mid frequencies and then falls again, providing a phase bump near crossover.
20+
- **Lag network** ($z > p$): the pole sits below the zero, so the compensator acts as a high-gain integrator approximation at low frequencies and rolls back to unity at high frequencies.
21+
22+
### Bilinear (Tustin) Discretization
23+
24+
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.
25+
26+
Define $c = 2/T_s$. The numerator and denominator polynomials in $z$ are:
27+
28+
$$n_0 = K(c + z), \quad n_1 = K(z - c)$$
29+
$$d_0 = c + p, \quad d_1 = p - c$$
30+
31+
Normalizing by $d_0$:
32+
33+
$$b_0 = \frac{n_0}{d_0}, \quad b_1 = \frac{n_1}{d_0}, \quad a_1 = \frac{d_1}{d_0}$$
34+
35+
### Discrete-Time Recurrence (Direct Form I)
36+
37+
$$y[n] = b_0 \, x[n] + b_1 \, x[n-1] - a_1 \, y[n-1]$$
38+
39+
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.
40+
41+
## Complexity Analysis
42+
43+
| Case | Time | Space | Notes |
44+
|---------|--------|--------|------------------------------------------------|
45+
| Best | $O(1)$ | $O(1)$ | Three multiply-adds, two state updates |
46+
| Average | $O(1)$ | $O(1)$ | Fixed instruction count per sample |
47+
| Worst | $O(1)$ | $O(1)$ | No branching; deterministic real-time behavior |
48+
49+
Design (Tustin coefficient computation) is $O(1)$ and occurs once in the constructor.
50+
51+
## Step-by-Step Walkthrough
52+
53+
Parameters: $K=1$, $z=1\,\text{rad/s}$, $p=10\,\text{rad/s}$, $T_s = 0.01\,\text{s}$ (lead network).
54+
55+
1. $c = 2/0.01 = 200$
56+
2. $n_0 = 1 \cdot (200 + 1) = 201$, $\quad n_1 = 1 \cdot (1 - 200) = -199$
57+
3. $d_0 = 200 + 10 = 210$, $\quad d_1 = 10 - 200 = -190$
58+
4. $b_0 = 201/210 \approx 0.9571$, $\quad b_1 = -199/210 \approx -0.9476$, $\quad a_1 = -190/210 \approx -0.9048$
59+
60+
Unit-step response (first two samples):
61+
62+
| $n$ | $x[n]$ | $b_0 x[n]$ | $b_1 x[n-1]$ | $-a_1 y[n-1]$ | $y[n]$ |
63+
|-----|--------|------------|--------------|---------------|--------|
64+
| 0 | 1 | 0.9571 | 0 | 0 | 0.9571 |
65+
| 1 | 1 | 0.9571 | −0.9476 | 0.8664 | 0.8759 |
66+
67+
The first output (≈ 0.957) already exceeds the DC steady-state gain of 0.1, illustrating the phase-lead kick.
68+
69+
Verification of DC gain: $b_0 + b_1 = 2/210$; $1 + a_1 = 20/210$; ratio $= 2/20 = 0.1 = K \cdot z/p$.
70+
71+
## Pitfalls & Edge Cases
72+
73+
- **Near-Nyquist poles/zeros**: when $z$ or $p$ is comparable to $\pi/T_s$, the bilinear transform introduces frequency warping. Pre-warp the analog corner frequencies to $\hat\omega = (2/T_s)\tan(\omega T_s/2)$ before applying Tustin if exact placement matters.
74+
- **Degenerate case $z = p$**: the compensator collapses to a pure gain $K$ with no dynamics. The discrete recurrence remains valid; the pole and zero cancel.
75+
- **Unstable discretization**: a plant with a very fast analog pole relative to $T_s$ can map outside the unit disk; verify $|a_1| < 1$ after computing coefficients.
76+
- **Floating-point accumulation**: the two state variables accumulate rounding error indefinitely. For long-running loops, periodic resets or double-precision state registers mitigate drift.
77+
78+
## Variants & Generalizations
79+
80+
- **Lead-lag cascade**: a lead section followed by a lag section in series provides simultaneous bandwidth improvement and steady-state accuracy. Reuse two first-order sections rather than chaining first-order blocks through a single instance.
81+
- **Phase-lead only / phase-lag only**: selecting $z$ and $p$ exclusively achieves single-objective shaping; the structure is unchanged.
82+
- **Pre-warped Tustin**: replace $z, p$ with $\hat z = (2/T_s)\tan(z T_s/2)$ and $\hat p = (2/T_s)\tan(p T_s/2)$ before computing Tustin coefficients to achieve exact frequency matching.
83+
- **Second-order extension**: cascading two first-order sections or using a biquad second-order section enables lead-lag-lead or other compound shapes.
84+
85+
## Applications
86+
87+
- **Motor velocity loops**: a lead compensator raises phase margin to allow a higher proportional gain, which directly increases bandwidth and disturbance rejection.
88+
- **Voltage regulators**: a lag compensator adds integrating action at mains frequency to eliminate steady-state regulation error without destabilizing the switching loop.
89+
- **Flight control inner loops**: classical lead-lag design from Bode plots remains the dominant method for aircraft inner-loop stabilization due to its transparency and robustness to model uncertainty.
90+
- **Thermal control**: a lag network boosts gain at low frequencies to null steady-state temperature offset while keeping the loop stable against slow sensor dynamics.
91+
92+
## Connections to Other Algorithms
93+
94+
- **BiquadCascade**: a second-order section generalizes to two poles and two zeros; cascading biquads is the standard approach when more than one lead-lag stage is required.
95+
- **PidIncremental**: a PID controller contains an implicit lead-lag structure; tuning the derivative and integral terms is equivalent to placing the compensator zero and pole.
96+
- **FrequencyResponse**: use the Bode magnitude and phase plots to verify that the shaped open-loop response achieves the desired gain crossover frequency and phase margin after adding the compensator.
97+
98+
## References & Further Reading
99+
100+
- G. F. Franklin, J. D. Powell, A. Emami-Naeini, *Feedback Control of Dynamic Systems*, 8th ed. (2019) — Chapter 6: The Frequency-Response Design Method.
101+
- K. J. Åström, R. M. Murray, *Feedback Systems: An Introduction for Scientists and Engineers* (2008), Chapter 9: Frequency Domain Design.
102+
- R. C. Dorf, R. H. Bishop, *Modern Control Systems*, 13th ed. (2017) — Lead and lag compensator design.

numerical/controllers/implementations/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ target_sources(numerical.controllers.implementations PRIVATE
1515
BangBangHysteresis.hpp
1616
Feedforward2Dof.hpp
1717
GainScheduledController.hpp
18+
LeadLagCompensator.hpp
1819
Lqg.hpp
1920
Lqr.hpp
2021
LuenbergerObserver.hpp
@@ -27,6 +28,7 @@ numerical_add_coverage_sources(numerical.controllers.implementations
2728
BangBangHysteresis.cpp
2829
Feedforward2Dof.cpp
2930
GainScheduledController.cpp
31+
LeadLagCompensator.cpp
3032
Lqg.cpp
3133
Lqr.cpp
3234
LuenbergerObserver.cpp
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#include "numerical/controllers/implementations/LeadLagCompensator.hpp"
2+
3+
namespace controllers
4+
{
5+
template class LeadLagCompensator<float>;
6+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 <type_traits>
9+
10+
namespace controllers
11+
{
12+
template<typename T>
13+
struct LeadLagParameters
14+
{
15+
static_assert(std::is_floating_point_v<T>, "LeadLagParameters supports floating-point types");
16+
17+
T gain;
18+
T zero;
19+
T pole;
20+
T sampleTime;
21+
};
22+
23+
template<typename T>
24+
class LeadLagCompensator
25+
{
26+
static_assert(std::is_floating_point_v<T>, "LeadLagCompensator supports floating-point types");
27+
28+
public:
29+
explicit LeadLagCompensator(LeadLagParameters<T> p);
30+
31+
OPTIMIZE_FOR_SPEED T Compute(T input);
32+
33+
void Reset(T value = T{ 0 });
34+
35+
private:
36+
T b0{};
37+
T b1{};
38+
T a1{};
39+
T prevInput{};
40+
T prevOutput{};
41+
};
42+
43+
template<typename T>
44+
LeadLagCompensator<T>::LeadLagCompensator(LeadLagParameters<T> p)
45+
{
46+
T c{ T{ 2 } / p.sampleTime };
47+
T n0{ p.gain * (c + p.zero) };
48+
T n1{ p.gain * (p.zero - c) };
49+
T d0{ c + p.pole };
50+
T d1{ p.pole - c };
51+
b0 = n0 / d0;
52+
b1 = n1 / d0;
53+
a1 = d1 / d0;
54+
}
55+
56+
template<typename T>
57+
OPTIMIZE_FOR_SPEED T LeadLagCompensator<T>::Compute(T input)
58+
{
59+
T output{ b0 * input + b1 * prevInput - a1 * prevOutput };
60+
prevInput = input;
61+
prevOutput = output;
62+
return output;
63+
}
64+
65+
template<typename T>
66+
void LeadLagCompensator<T>::Reset(T value)
67+
{
68+
prevInput = value;
69+
prevOutput = value;
70+
}
71+
72+
#ifdef NUMERICAL_TOOLBOX_COVERAGE_BUILD
73+
extern template class LeadLagCompensator<float>;
74+
#endif
75+
}

numerical/controllers/implementations/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ target_sources(numerical.controllers_test PRIVATE
1212
TestBangBangHysteresis.cpp
1313
TestFeedforward2Dof.cpp
1414
TestGainScheduledController.cpp
15+
TestLeadLagCompensator.cpp
1516
TestLqg.cpp
1617
TestLqr.cpp
1718
TestLuenbergerObserver.cpp
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#include "numerical/controllers/implementations/LeadLagCompensator.hpp"
2+
#include "numerical/math/Tolerance.hpp"
3+
#include "gtest/gtest.h"
4+
5+
namespace
6+
{
7+
class TestLeadLagCompensator
8+
: public ::testing::Test
9+
{
10+
public:
11+
controllers::LeadLagParameters<float> params{ 1.0f, 1.0f, 10.0f, 0.01f };
12+
controllers::LeadLagCompensator<float> comp{ params };
13+
};
14+
}
15+
16+
TEST_F(TestLeadLagCompensator, dc_gain_matches_continuous)
17+
{
18+
for (int i = 0; i < 2000; ++i)
19+
comp.Compute(1.0f);
20+
21+
float steadyState{ comp.Compute(1.0f) };
22+
float expected{ params.gain * params.zero / params.pole };
23+
EXPECT_NEAR(steadyState, expected, 1e-3f);
24+
}
25+
26+
TEST_F(TestLeadLagCompensator, lead_produces_initial_overshoot)
27+
{
28+
float firstOut{ comp.Compute(1.0f) };
29+
30+
for (int i = 0; i < 2000; ++i)
31+
comp.Compute(1.0f);
32+
float steadyState{ comp.Compute(1.0f) };
33+
34+
EXPECT_GT(firstOut, steadyState);
35+
}
36+
37+
TEST_F(TestLeadLagCompensator, lag_has_no_derivative_kick)
38+
{
39+
controllers::LeadLagParameters<float> lagParams{ 1.0f, 10.0f, 1.0f, 0.01f };
40+
controllers::LeadLagCompensator<float> lagComp{ lagParams };
41+
42+
float prev{ lagComp.Compute(1.0f) };
43+
bool monotone{ true };
44+
for (int i = 0; i < 100; ++i)
45+
{
46+
float cur{ lagComp.Compute(1.0f) };
47+
if (cur < prev)
48+
{
49+
monotone = false;
50+
break;
51+
}
52+
prev = cur;
53+
}
54+
EXPECT_TRUE(monotone);
55+
}
56+
57+
TEST_F(TestLeadLagCompensator, impulse_response_is_stable)
58+
{
59+
comp.Compute(1.0f);
60+
for (int i = 0; i < 1000; ++i)
61+
comp.Compute(0.0f);
62+
63+
float tail{ comp.Compute(0.0f) };
64+
EXPECT_NEAR(tail, 0.0f, 1e-3f);
65+
}
66+
67+
TEST_F(TestLeadLagCompensator, coefficients_match_tustin_design)
68+
{
69+
float c{ 2.0f / params.sampleTime };
70+
float d0{ c + params.pole };
71+
float expectedB0{ params.gain * (c + params.zero) / d0 };
72+
float expectedB1{ params.gain * (params.zero - c) / d0 };
73+
float expectedA1{ (params.pole - c) / d0 };
74+
75+
float y0{ comp.Compute(1.0f) };
76+
EXPECT_NEAR(y0, expectedB0, 1e-5f);
77+
78+
float y1{ comp.Compute(0.0f) };
79+
EXPECT_NEAR(y1, expectedB1 - expectedA1 * y0, 1e-5f);
80+
}
81+
82+
TEST_F(TestLeadLagCompensator, reset_clears_history)
83+
{
84+
for (int i = 0; i < 10; ++i)
85+
comp.Compute(1.0f);
86+
87+
comp.Reset(0.0f);
88+
89+
float c{ 2.0f / params.sampleTime };
90+
float d0{ c + params.pole };
91+
float b0{ params.gain * (c + params.zero) / d0 };
92+
93+
float nextInput{ 0.5f };
94+
float out{ comp.Compute(nextInput) };
95+
EXPECT_NEAR(out, b0 * nextInput, 1e-5f);
96+
}
97+
98+
TEST_F(TestLeadLagCompensator, step_reaches_expected_steady_state)
99+
{
100+
for (int i = 0; i < 5000; ++i)
101+
comp.Compute(1.0f);
102+
103+
float finalVal{ comp.Compute(1.0f) };
104+
float expected{ params.gain * params.zero / params.pole };
105+
EXPECT_NEAR(finalVal, expected, 1e-3f);
106+
}
107+
108+
TEST_F(TestLeadLagCompensator, unity_when_zero_equals_pole)
109+
{
110+
controllers::LeadLagParameters<float> unityParams{ 2.0f, 5.0f, 5.0f, 0.01f };
111+
controllers::LeadLagCompensator<float> unityComp{ unityParams };
112+
113+
for (int i = 0; i < 2000; ++i)
114+
unityComp.Compute(1.0f);
115+
116+
float steadyState{ unityComp.Compute(1.0f) };
117+
EXPECT_NEAR(steadyState, unityParams.gain, 1e-3f);
118+
}

roadmap/controllers/LeadLagCompensator/explanation.md

Lines changed: 0 additions & 32 deletions
This file was deleted.

0 commit comments

Comments
 (0)