Skip to content

Commit ccaa53e

Browse files
feat: add disturbance observer (#209)
* add disturbance observer * Apply suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * remove duplication --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 7d5f1ee commit ccaa53e

12 files changed

Lines changed: 537 additions & 181 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Refer to the documentation to quickly integrate and utilize the library's signal
2828
| [Regularization](doc/regularization/README.md) | L1 (Lasso), L2 (Ridge) |
2929
| [Math](doc/math/README.md) | CORDIC, Quaternion, MatrixNorms, Step Response Metrics, MatrixExponential |
3030
| [Solvers](doc/solvers/README.md) | Gaussian Elimination, Levinson-Durbin, Durand-Kerner, Cholesky, DARE, Runge-Kutta ODE Integrators (RK4 + Dormand-Prince), Spectral Radius & Discrete Stability Margin, QR Decomposition (Householder / Givens), LU Decomposition with Partial Pivoting |
31-
| [Robust Control](doc/robust_control/README.md) | Sliding Mode Control (SMC) |
31+
| [Robust Control](doc/robust_control/README.md) | Sliding Mode Control (SMC), Disturbance Observer (DOB) |
3232
| [Performance Optimization](doc/performance-optimization/README.md) | Compiler optimizations, SIMD |
3333

3434
Each category page lists its algorithms with a brief description and links to the detailed documentation.

ROADMAP.md

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

2828
| # | Component | Target module | Difficulty |
2929
|----|------------------------------------------------------|---------------------------|------------|
30-
| 35 | Disturbance Observer (DOB) | `robust_control` (new) | ★★★★☆ |
3130
| 36 | Active Disturbance Rejection Control (ADRC + ESO) | `robust_control` (new) | ★★★★☆ |
3231
| 37 | Hilbert transform / analytic signal / envelope | `analysis` | ★★★★☆ |
3332
| 38 | Discrete Wavelet Transform (Haar / Daubechies) | `analysis` | ★★★★☆ |
@@ -207,12 +206,6 @@ the library does not yet expose. Detailed below under
207206
- **Reuses:** **Item 18 (Quaternion)**, `math::Geometry3D`.
208207

209208

210-
### 35. Disturbance Observer (DOB) *(float-first)*
211-
- **What:** Estimates and cancels lumped disturbance/model mismatch using the plant inverse and a Q-filter.
212-
- **Embedded value:** Bolt-on robustness for existing loops — strong disturbance rejection without redesigning the nominal controller.
213-
- **Algorithm / paper:** W.-H. Chen, J. Yang, L. Guo, S. Li, "Disturbance-Observer-Based Control and Related Methods—An Overview," *IEEE Trans. Ind. Electron.*, 63(2), 2016.
214-
- **Reuses:** Item 15 (Q-filter), item 19 (observer), `math::LinearTimeInvariant`.
215-
216209
### 36. Active Disturbance Rejection Control (ADRC + ESO) *(float-first)*
217210
- **What:** Extended State Observer estimates total disturbance as an augmented state; a feedback law cancels it in real time.
218211
- **Embedded value:** Near model-free, strongly robust motion control; increasingly standard in industrial drives.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Disturbance Observer
2+
3+
## Overview & Motivation
4+
5+
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.
6+
7+
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.
8+
9+
## Mathematical Theory
10+
11+
### Setup
12+
13+
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
14+
15+
$$y = P(z)\, u_a = P(z)(u + d).$$
16+
17+
### Disturbance Estimate
18+
19+
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:
20+
21+
$$P_n^{-1}(z)\, y \approx u + d \quad \text{(if } P \approx P_n\text{)}.$$
22+
23+
Subtracting the actual commanded input $u$ isolates the disturbance:
24+
25+
$$\hat{d} = P_n^{-1}(z)\, y - u.$$
26+
27+
### Q-Filter and Properness
28+
29+
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:
30+
31+
$$\hat{d} = Q(z)\,P_n^{-1}(z)\, y - Q(z)\, u.$$
32+
33+
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.
34+
35+
### Control Law
36+
37+
The DOB corrects the nominal controller output $c$ by subtracting the estimate:
38+
39+
$$u = c - \hat{d}.$$
40+
41+
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.
42+
43+
### Frequency-Domain Interpretation
44+
45+
Let $L(z) = Q(z)\,P_n^{-1}(z)\,P(z)$. The closed-loop sensitivity from disturbance $d$ to output $y$ is
46+
47+
$$S_d(z) = \frac{P(z)(1 - Q(z))}{1 + P(z)C(z)(1 - Q(z))}.$$
48+
49+
Inside the Q-filter passband ($Q \approx 1$): $S_d \approx 0$ — the disturbance is rejected.
50+
Outside the passband ($Q \approx 0$): $S_d$ equals the nominal sensitivity — the DOB is transparent.
51+
52+
### Stability Robustness
53+
54+
Robust stability requires the complementary sensitivity of the inner DOB loop to satisfy
55+
56+
$$\left|Q(e^{j\omega})\,\Delta_m(e^{j\omega})\right| < 1 \quad \forall\, \omega,$$
57+
58+
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.
59+
60+
## Complexity Analysis
61+
62+
| Operation | Time | Space | Notes |
63+
|-----------|------------------|--------------|------------------------------------------------|
64+
| Construct | $O(N \cdot S^2)$ | $O(S^2 + N)$ | DC gain simulation, $S$ = StateSize, $N$ = 512 |
65+
| Compute | $O(N_{\rm in})$ | $O(1)$ extra | Per-channel biquad filter pair |
66+
| Reset | $O(N_{\rm in})$ | $O(1)$ extra | Clears filter states |
67+
68+
All storage is fixed-size; no heap allocation occurs at any point in the lifecycle.
69+
70+
## Step-by-Step Walkthrough
71+
72+
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).
73+
74+
**Steady-state with constant disturbance $d = 0.5$, nominal command $c = 0$:**
75+
76+
1. Plant output settles to $y_{ss} = P(1)\,d = 1 \cdot 0.5 = 0.5$.
77+
2. Q-filter path 1: $Q(1)\,P_n^{-1}(1)\,y_{ss} = 1 \cdot 1 \cdot 0.5 = 0.5$.
78+
3. Q-filter path 2: $Q(1)\,u_{ss} = 1 \cdot (c - \hat{d})_{ss}$.
79+
4. At equilibrium path 1 $-$ path 2 $= \hat{d}$ and $u_{ss} = c - \hat{d}$, giving $\hat{d} = 0.5 = d$. The estimate converges exactly.
80+
5. The corrected input is $u = 0 - 0.5 = -0.5$, so the effective input to the plant is $-0.5 + 0.5 = 0$ — the disturbance is cancelled.
81+
82+
## Pitfalls & Edge Cases
83+
84+
- **Non-minimum-phase plants**: $P_n^{-1}(z)$ has unstable poles when $P_n$ has zeros outside the unit circle. The DOB inner loop becomes unstable; non-minimum-phase zeros must be treated specially or the DOB must not be applied directly.
85+
- **DC gain of zero**: if the nominal plant has no steady-state response to the input, the inverse gain is ill-defined. The implementation guards against division by zero but the DOB will not function correctly.
86+
- **Wide Q bandwidth**: increasing the cutoff trades rejection bandwidth for noise amplification and reduced robustness to model mismatch. The trade-off is captured by the robust-stability bound above.
87+
- **Large model mismatch**: when $|\Delta_m|$ is not small, the DOB may amplify rather than cancel the disturbance. The bandwidth of $Q$ must be restricted so the robustness condition holds across the frequency range of significant mismatch.
88+
- **Unstable nominal plant**: the steady-state DC-gain simulation used during construction diverges; only stable nominal plants are supported.
89+
90+
## Variants & Generalizations
91+
92+
- **Two-degree-of-freedom DOB**: a separate reference pre-filter shapes the tracking response independently of the disturbance rejection channel.
93+
- **Nonlinear DOB**: replaces the linear inverse with a nonlinear observer (e.g., extended high-gain observer) for plants with known nonlinear structure.
94+
- **Time-varying Q**: adapts the Q-filter bandwidth online to balance rejection versus robustness as operating conditions change.
95+
- **Multi-input multi-output (MIMO) DOB**: generalises the scalar channel-pairing to full matrix $P_n^{-1}$, requiring the nominal plant to be square and invertible.
96+
97+
## Applications
98+
99+
- Precision motion control: rejects cutting forces in CNC machines and friction in ball-screw drives.
100+
- Robotic joint torque control: cancels gravity, Coriolis, and friction terms without explicit model inversion.
101+
- Hard-disk drive servo: one of the earliest industrial applications; Q-filter bandwidth sets the track-following bandwidth.
102+
- Power electronics: rejects grid-voltage disturbances in inverter current control.
103+
104+
## Connections to Other Algorithms
105+
106+
- **Sliding Mode Control**: an alternative approach to matched-disturbance rejection via a switching term; SMC is discontinuous and model-free while the DOB is smooth and model-based.
107+
- **Luenberger Observer / Kalman Filter**: estimate state from measurements; the DOB estimates disturbance from input-output pairs without augmenting the state.
108+
- **Active Disturbance Rejection Control (ADRC)**: treats total disturbance as an augmented state in a full observer; conceptually similar to DOB but parameterised through observer bandwidth rather than a Q-filter.
109+
- **BiquadCascade**: the Q-filter is realised directly as a second-order IIR section.
110+
111+
## References & Further Reading
112+
113+
- W.-H. Chen, J. Yang, L. Guo, S. Li, "Disturbance-Observer-Based Control and Related Methods — An Overview," *IEEE Transactions on Industrial Electronics*, 63(2), pp. 1083–1095, 2016.
114+
- K. Ohishi, M. Nakao, K. Ohnishi, K. Miyachi, "Microprocessor-Controlled DC Motor for Load-Insensitive Position Servo System," *IEEE Transactions on Industrial Electronics*, 34(1), pp. 44–49, 1987.
115+
- E. Schrijver, J. van Dijk, "Disturbance Observers for Rigid Mechanical Systems: Equivalence, Stability, and Design," *ASME Journal of Dynamic Systems, Measurement, and Control*, 124(4), pp. 539–548, 2002.
116+
- S. Komada, K. Ohnishi, "Force Feedback Control of Robot Manipulator by the Acceleration Tracing Orientation Method," *IEEE Transactions on Industrial Electronics*, 37(1), pp. 6–12, 1990.

doc/robust_control/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ Algorithms for robust control design: controllers that explicitly account for di
66

77
| Algorithm | Description |
88
|-----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
9-
| [Sliding Mode Control](SlidingModeControl.md) | Variable-structure controller driving the state onto a sliding surface with a boundary layer to suppress chattering — robust to matched disturbances and parameter uncertainty |
9+
| [Sliding Mode Control](SlidingModeControl.md) | Variable-structure controller driving the state onto a sliding surface with a boundary layer to suppress chattering — robust to matched disturbances and parameter uncertainty |
10+
| [Disturbance Observer](DisturbanceObserver.md) | Estimates lumped disturbance and model mismatch via the nominal plant inverse and a Q-filter, cancelling the disturbance to make the real plant behave like the nominal model |

numerical/robust_control/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@ target_include_directories(numerical.robust_control ${NUMERICAL_VISIBILITY}
77

88
target_link_libraries(numerical.robust_control ${NUMERICAL_VISIBILITY}
99
numerical.math
10+
numerical.filters.passive
1011
numerical.solver
1112
infra.util
1213
)
1314

1415
target_sources(numerical.robust_control PRIVATE
16+
DisturbanceObserver.hpp
1517
SlidingModeControl.hpp
1618
)
1719

1820
numerical_add_coverage_sources(numerical.robust_control
21+
DisturbanceObserver.cpp
1922
SlidingModeControl.cpp
2023
)
2124

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Copyright (c) 2024, Numerical Toolbox Contributors. All rights reserved.
2+
#include "numerical/robust_control/DisturbanceObserver.hpp"
3+
4+
namespace robust_control
5+
{
6+
template class DisturbanceObserver<float, 1, 1, 1>;
7+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) 2024, Numerical Toolbox Contributors. All rights reserved.
2+
#pragma once
3+
4+
#if defined(__GNUC__) || defined(__clang__)
5+
#pragma GCC optimize("O3", "fast-math")
6+
#endif
7+
8+
#include "numerical/filters/passive/BiquadCascade.hpp"
9+
#include "numerical/math/CompilerOptimizations.hpp"
10+
#include "numerical/math/LinearTimeInvariant.hpp"
11+
#include "numerical/math/Matrix.hpp"
12+
#include <array>
13+
#include <cmath>
14+
#include <cstddef>
15+
#include <type_traits>
16+
17+
namespace robust_control
18+
{
19+
template<typename T,
20+
std::size_t StateSize,
21+
std::size_t InputSize,
22+
std::size_t OutputSize>
23+
class DisturbanceObserver
24+
{
25+
static_assert(std::is_floating_point_v<T>, "DisturbanceObserver supports floating-point types");
26+
static_assert(StateSize > 0 && InputSize > 0 && OutputSize > 0,
27+
"DisturbanceObserver requires positive dimensions");
28+
static_assert(InputSize == OutputSize,
29+
"DisturbanceObserver requires InputSize == OutputSize for per-channel disturbance pairing");
30+
31+
public:
32+
using PlantType = math::LinearTimeInvariant<T, StateSize, InputSize, OutputSize>;
33+
using InputVector = math::Vector<T, InputSize>;
34+
using OutputVector = math::Vector<T, OutputSize>;
35+
36+
using QCoeffs = filters::passive::BiquadCoeffs<T>;
37+
38+
DisturbanceObserver(const PlantType& nominalPlant, const QCoeffs& q);
39+
40+
OPTIMIZE_FOR_SPEED InputVector Compute(const InputVector& nominalControl,
41+
const OutputVector& measuredOutput);
42+
43+
[[nodiscard]] const InputVector& Disturbance() const;
44+
45+
void Reset();
46+
47+
private:
48+
static std::array<T, InputSize> ComputeDcGainInverses(const PlantType& plant);
49+
static std::array<filters::passive::BiquadCascade<T, 1>, InputSize> MakeQFilters(const QCoeffs& q);
50+
51+
PlantType nominalPlant;
52+
std::array<T, InputSize> dcGainInv;
53+
std::array<filters::passive::BiquadCascade<T, 1>, InputSize> qInvFilters;
54+
std::array<filters::passive::BiquadCascade<T, 1>, InputSize> qFilters;
55+
InputVector disturbance{};
56+
InputVector appliedPrev{};
57+
};
58+
59+
namespace detail
60+
{
61+
template<typename T, std::size_t N>
62+
T ComputeSisoSteadyStateDcGain(
63+
const math::LinearTimeInvariant<T, N, 1, 1>& plant)
64+
{
65+
math::Vector<T, N> x{};
66+
math::Vector<T, 1> u{};
67+
u.at(0, 0) = T{ 1 };
68+
for (std::size_t k{ 0 }; k < 512; ++k)
69+
x = plant.Step(x, u);
70+
const auto y{ plant.Output(x, u) };
71+
return y.at(0, 0);
72+
}
73+
}
74+
75+
template<typename T, std::size_t StateSize, std::size_t InputSize, std::size_t OutputSize>
76+
std::array<T, InputSize>
77+
DisturbanceObserver<T, StateSize, InputSize, OutputSize>::ComputeDcGainInverses(
78+
const PlantType& plant)
79+
{
80+
std::array<T, InputSize> result{};
81+
for (std::size_t ch{ 0 }; ch < InputSize; ++ch)
82+
{
83+
using SisoPlant = math::LinearTimeInvariant<T, StateSize, 1, 1>;
84+
SisoPlant siso{};
85+
for (std::size_t r{ 0 }; r < StateSize; ++r)
86+
{
87+
for (std::size_t c{ 0 }; c < StateSize; ++c)
88+
siso.A.at(r, c) = plant.A.at(r, c);
89+
siso.B.at(r, 0) = plant.B.at(r, ch);
90+
siso.C.at(0, r) = plant.C.at(ch, r);
91+
}
92+
siso.D.at(0, 0) = plant.D.at(ch, ch);
93+
94+
const T gain{ detail::ComputeSisoSteadyStateDcGain(siso) };
95+
result[ch] = (gain == T{ 0 }) ? T{ 1 } : T{ 1 } / gain;
96+
}
97+
return result;
98+
}
99+
100+
template<typename T, std::size_t StateSize, std::size_t InputSize, std::size_t OutputSize>
101+
DisturbanceObserver<T, StateSize, InputSize, OutputSize>::DisturbanceObserver(
102+
const PlantType& nominalPlant,
103+
const QCoeffs& q)
104+
: nominalPlant{ nominalPlant }
105+
, dcGainInv{ ComputeDcGainInverses(nominalPlant) }
106+
, qInvFilters{ MakeQFilters(q) }
107+
, qFilters{ MakeQFilters(q) }
108+
{}
109+
110+
template<typename T, std::size_t StateSize, std::size_t InputSize, std::size_t OutputSize>
111+
std::array<filters::passive::BiquadCascade<T, 1>, InputSize>
112+
DisturbanceObserver<T, StateSize, InputSize, OutputSize>::MakeQFilters(const QCoeffs& q)
113+
{
114+
return [&]<std::size_t... Is>(std::index_sequence<Is...>)
115+
-> std::array<filters::passive::BiquadCascade<T, 1>, InputSize>
116+
{
117+
return { ((void)Is, filters::passive::BiquadCascade<T, 1>{ { q } })... };
118+
}(std::make_index_sequence<InputSize>{});
119+
}
120+
121+
template<typename T, std::size_t StateSize, std::size_t InputSize, std::size_t OutputSize>
122+
OPTIMIZE_FOR_SPEED typename DisturbanceObserver<T, StateSize, InputSize, OutputSize>::InputVector
123+
DisturbanceObserver<T, StateSize, InputSize, OutputSize>::Compute(
124+
const InputVector& nominalControl,
125+
const OutputVector& measuredOutput)
126+
{
127+
for (std::size_t ch{ 0 }; ch < InputSize; ++ch)
128+
{
129+
const T a{ qInvFilters[ch].Filter(measuredOutput.at(ch, 0) * dcGainInv[ch]) };
130+
const T b{ qFilters[ch].Filter(appliedPrev.at(ch, 0)) };
131+
disturbance.at(ch, 0) = a - b;
132+
}
133+
134+
InputVector u{};
135+
for (std::size_t ch{ 0 }; ch < InputSize; ++ch)
136+
u.at(ch, 0) = nominalControl.at(ch, 0) - disturbance.at(ch, 0);
137+
138+
appliedPrev = u;
139+
return u;
140+
}
141+
142+
template<typename T, std::size_t StateSize, std::size_t InputSize, std::size_t OutputSize>
143+
const typename DisturbanceObserver<T, StateSize, InputSize, OutputSize>::InputVector&
144+
DisturbanceObserver<T, StateSize, InputSize, OutputSize>::Disturbance() const
145+
{
146+
return disturbance;
147+
}
148+
149+
template<typename T, std::size_t StateSize, std::size_t InputSize, std::size_t OutputSize>
150+
void DisturbanceObserver<T, StateSize, InputSize, OutputSize>::Reset()
151+
{
152+
disturbance = InputVector{};
153+
appliedPrev = InputVector{};
154+
for (std::size_t ch{ 0 }; ch < InputSize; ++ch)
155+
{
156+
qInvFilters[ch].Reset();
157+
qFilters[ch].Reset();
158+
}
159+
}
160+
161+
#ifdef NUMERICAL_TOOLBOX_COVERAGE_BUILD
162+
extern template class DisturbanceObserver<float, 1, 1, 1>;
163+
#endif
164+
}

numerical/robust_control/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ target_link_libraries(numerical.robust_control_test PUBLIC
88
)
99

1010
target_sources(numerical.robust_control_test PRIVATE
11+
TestDisturbanceObserver.cpp
1112
TestSlidingModeControl.cpp
1213
)

0 commit comments

Comments
 (0)