Skip to content

Commit dbd43ec

Browse files
fix(adrc): add 3-arg Compute for actuator saturation anti-windup (#276) (#282)
When the downstream actuator clips the commanded value, the ESO was integrating the wrong input, causing the disturbance estimate to drift and producing excessive overshoot. The new Compute(reference, measuredOutput, actualApplied) overload lets callers close the anti-windup loop by feeding the actually-applied signal back into the ESO. The two-argument overload delegates to it for zero-change backwards compatibility. Also fixes a pre-existing EXPECT_FLOAT_EQ in reset_mid_run_matches_fresh_instance that failed under fast-math due to register-allocation differences between differently-addressed objects; replaced with EXPECT_NEAR using math::Tolerance. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 13fba55 commit dbd43ec

3 files changed

Lines changed: 32 additions & 5 deletions

File tree

doc/robust_control/ActiveDisturbanceRejection.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,16 @@ At sample $k$ with state $\hat{x} = [\hat{y}, \hat{\dot{y}}, \hat{f}]$, measurem
7474

7575
1. Output error: $e = y[k] - \hat{y}$.
7676
2. Inject correction into all three states: $\hat{x}_i \mathrel{+}= T_s \beta_i e$.
77-
3. Chain integration: $\hat{y} \mathrel{+}= T_s \hat{\dot{y}}$; then $\hat{\dot{y}} \mathrel{+}= T_s b_0 u[k-1]$.
77+
3. Chain integration: $\hat{y} \mathrel{+}= T_s \hat{\dot{y}}$; then $\hat{\dot{y}} \mathrel{+}= T_s b_0 u_\text{applied}[k-1]$, where $u_\text{applied}$ is the value actually delivered by the actuator. When the actuator does not saturate this equals the commanded $u[k-1]$; when it does, pass the clipped value via the three-argument `Compute(reference, measuredOutput, actualApplied)` overload.
7878
4. PD law on integrator chain: $u_0 = k_p(r - \hat{y}) - k_d \hat{\dot{y}}$.
7979
5. Disturbance cancellation: $u = (u_0 - \hat{f}) / b_0$.
8080

8181
After a transient of roughly $5/\omega_o \approx 0.17$ s the observer converges; the output tracks $r$ with bandwidth $\omega_c$.
8282

8383
## Pitfalls & Edge Cases
8484

85+
**Actuator saturation (anti-windup).** When the downstream actuator clips the commanded value, the ESO integrates the wrong input and its disturbance estimate drifts, causing significant overshoot. Close the anti-windup loop by feeding the actually-applied signal back: `u_applied = clamp(Compute(r, y, u_applied_prev), lo, hi)`, then pass `u_applied` as the third argument on the next call. The two-argument `Compute(reference, measuredOutput)` is a convenience overload that assumes no saturation (commanded = applied) and is equivalent to passing the last commanded value.
86+
8587
**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.
8688

8789
**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]$.

numerical/robust_control/ActiveDisturbanceRejection.hpp

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ namespace robust_control
2727

2828
ActiveDisturbanceRejectionControl(T observerBandwidth, T controlBandwidth, T b0, T sampleTime);
2929

30+
OPTIMIZE_FOR_SPEED T Compute(T reference, T measuredOutput, T actualApplied);
3031
OPTIMIZE_FOR_SPEED T Compute(T reference, T measuredOutput);
3132
void Reset();
3233

@@ -75,7 +76,7 @@ namespace robust_control
7576
}
7677

7778
template<typename T, std::size_t Order>
78-
OPTIMIZE_FOR_SPEED T ActiveDisturbanceRejectionControl<T, Order>::Compute(T reference, T measuredOutput)
79+
OPTIMIZE_FOR_SPEED T ActiveDisturbanceRejectionControl<T, Order>::Compute(T reference, T measuredOutput, T actualApplied)
7980
{
8081
const T e = measuredOutput - xhat.at(0, 0);
8182

@@ -85,7 +86,7 @@ namespace robust_control
8586
for (std::size_t i = 0; i < Order; ++i)
8687
xhat.at(i, 0) += sampleTime * xhat.at(i + 1, 0);
8788

88-
xhat.at(Order - 1, 0) += sampleTime * b0 * appliedPrev;
89+
xhat.at(Order - 1, 0) += sampleTime * b0 * actualApplied;
8990

9091
T u0 = controlGain.at(0, 0) * (reference - xhat.at(0, 0));
9192
for (std::size_t i = 1; i < Order; ++i)
@@ -96,6 +97,12 @@ namespace robust_control
9697
return u;
9798
}
9899

100+
template<typename T, std::size_t Order>
101+
OPTIMIZE_FOR_SPEED T ActiveDisturbanceRejectionControl<T, Order>::Compute(T reference, T measuredOutput)
102+
{
103+
return Compute(reference, measuredOutput, appliedPrev);
104+
}
105+
99106
template<typename T, std::size_t Order>
100107
void ActiveDisturbanceRejectionControl<T, Order>::Reset()
101108
{

numerical/robust_control/test/TestActiveDisturbanceRejection.cpp

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#include "numerical/math/Tolerance.hpp"
22
#include "numerical/robust_control/ActiveDisturbanceRejection.hpp"
3+
#include <algorithm>
34
#include <cmath>
45
#include <gtest/gtest.h>
56

@@ -222,8 +223,8 @@ TEST_F(TestActiveDisturbanceRejection, reset_mid_run_matches_fresh_instance)
222223
freshPlant.Step(uFresh);
223224
}
224225

225-
EXPECT_FLOAT_EQ(plant.y, freshPlant.y);
226-
EXPECT_FLOAT_EQ(adrc.AppliedPrev(), fresh.AppliedPrev());
226+
EXPECT_NEAR(plant.y, freshPlant.y, math::Tolerance<float>());
227+
EXPECT_NEAR(adrc.AppliedPrev(), fresh.AppliedPrev(), math::Tolerance<float>());
227228
}
228229

229230
TEST_F(TestActiveDisturbanceRejection, eso_stable_at_high_observer_bandwidth)
@@ -266,3 +267,20 @@ TEST_F(TestBinomialCoeff, known_interior_values)
266267
EXPECT_EQ(robust_control::detail::BinomialCoeff(4, 2), 6u);
267268
EXPECT_EQ(robust_control::detail::BinomialCoeff(5, 3), 10u);
268269
}
270+
271+
TEST_F(TestActiveDisturbanceRejection, tracks_step_reference_with_saturated_actuator)
272+
{
273+
const float reference{ 1.0f };
274+
const float satLimit{ 5.0f };
275+
float actualApplied{ 0.0f };
276+
277+
for (int i = 0; i < 8000; ++i)
278+
{
279+
const float commanded = adrc.Compute(reference, plant.y, actualApplied);
280+
actualApplied = std::clamp(commanded, -satLimit, satLimit);
281+
plant.Step(actualApplied);
282+
}
283+
284+
EXPECT_NEAR(plant.y, reference, 1e-2f);
285+
EXPECT_NEAR(adrc.EstimatedState().at(0, 0), reference, 1e-2f);
286+
}

0 commit comments

Comments
 (0)