Summary
ActiveDisturbanceRejectionControl::Compute() ends with:
const T u = (u0 - xhat.at(Order, 0)) / b0;
appliedPrev = u;
return u;
appliedPrev is the value the controller commanded. On the next call the ESO propagates the plant model with it:
xhat.at(Order - 1, 0) += sampleTime * b0 * appliedPrev;
Whenever the command is clipped by an actuator envelope, the observer integrates an input the plant never received. The mismatch is absorbed into the disturbance estimate f̂, which drifts for as long as the clip lasts and then has to unwind.
AppliedPrev() is a read-only accessor and there is no setter or Compute overload accepting the applied value, so a caller that saturates downstream has no way to keep the observer honest. Reset() is the only lever and it discards the whole estimate.
Impact
Any real drive saturates on a large setpoint step. With the observer misinformed, the disturbance estimate biases the command well past the point where the reference is reached.
Reproduction
#include "numerical/robust_control/ActiveDisturbanceRejection.hpp"
#include <algorithm>
#include <cstdio>
// Mechanical plant w[k+1] = 0.999 w[k] + 0.5 u[k], b0 = 500, Ts = 1 ms.
// wc = 188.5 and wo = 400 rad/s are both well inside the stability region.
int main()
{
for (float envelope : { 1.0e9f, 10.0f })
{
robust_control::ActiveDisturbanceRejectionControl<float, 1> adrc{ 400.0f, 188.5f, 500.0f, 0.001f };
float speed{ 0.0f }, peakSpeed{ 0.0f }, peakCommand{ 0.0f };
for (int step = 0; step != 8000; ++step)
{
auto commanded = adrc.Compute(100.0f, speed);
peakCommand = std::max(peakCommand, std::abs(commanded));
speed = 0.999f * speed + 0.5f * std::clamp(commanded, -envelope, envelope);
peakSpeed = std::max(peakSpeed, speed);
}
std::printf("envelope=%9.4g peak command=%.4g peak speed=%.5g settled=%.5g\n",
envelope, peakCommand, peakSpeed, speed);
}
}
Output:
envelope= 1e+09 peak command=37.7 peak speed=100.36 settled=100
envelope= 10 peak command=37.7 peak speed=127.68 settled=100
Same controller, same tuning, same plant. The only difference is that the second run clips the command at 10 A while the observer keeps integrating 37.7 A. Overshoot goes from 0.4 % to 28 %. The loop does recover, so this is windup rather than divergence, but the transient is entirely an artifact of the observer being fed the wrong input.
Suggested direction
Give the caller a way to close the loop around the real actuator. Either:
T Compute(T reference, T measuredOutput, T appliedPrevious);
or
void SetAppliedPrevious(T applied);
The first is preferable — it makes the correct usage the obvious one, and the existing two-argument form can forward with appliedPrev.
Worth a note in doc/robust_control/ActiveDisturbanceRejection.md as well. The document covers ESO peaking under saturation as an initialization problem, but not this steady-state input mismatch.
Summary
ActiveDisturbanceRejectionControl::Compute()ends with:appliedPrevis the value the controller commanded. On the next call the ESO propagates the plant model with it:Whenever the command is clipped by an actuator envelope, the observer integrates an input the plant never received. The mismatch is absorbed into the disturbance estimate
f̂, which drifts for as long as the clip lasts and then has to unwind.AppliedPrev()is a read-only accessor and there is no setter orComputeoverload accepting the applied value, so a caller that saturates downstream has no way to keep the observer honest.Reset()is the only lever and it discards the whole estimate.Impact
Any real drive saturates on a large setpoint step. With the observer misinformed, the disturbance estimate biases the command well past the point where the reference is reached.
Reproduction
Output:
Same controller, same tuning, same plant. The only difference is that the second run clips the command at 10 A while the observer keeps integrating 37.7 A. Overshoot goes from 0.4 % to 28 %. The loop does recover, so this is windup rather than divergence, but the transient is entirely an artifact of the observer being fed the wrong input.
Suggested direction
Give the caller a way to close the loop around the real actuator. Either:
T Compute(T reference, T measuredOutput, T appliedPrevious);or
The first is preferable — it makes the correct usage the obvious one, and the existing two-argument form can forward with
appliedPrev.Worth a note in
doc/robust_control/ActiveDisturbanceRejection.mdas well. The document covers ESO peaking under saturation as an initialization problem, but not this steady-state input mismatch.