Skip to content

ADRC ESO is fed the commanded input, not the applied one, so it winds up on actuator saturation #276

Description

@gabrielsantosphilips

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 , 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions