Summary
controllers::DeadbeatControl with Steps > 1 never reaches its reference when used in receding-horizon mode (the mode ComputeControl implies). The closed loop settles to a fraction of the commanded value. Steps == 1 is correct and unaffected.
Introduced in #271.
Root cause
ComputeGains assigns the reference gain directly from the pseudo-inverse block:
auto gammaPinv = solvers::SolveSystem<T, StateSize, ReachSize>(gamma * gamma.Transpose(), gamma).Transpose();
gainRef = gammaPinv.template GetBlock<InputSize, StateSize>(0, 0);
gainState = gainRef * AN;
This is the minimum-norm solution for the whole input sequence [u[k] … u[k+N-1]], and it is correct if that entire sequence is applied open-loop — the state then reaches the reference in exactly N steps.
But ComputeControl returns only the first element and is called again every sample, so the effective closed loop is:
x[k+1] = (A - B·gainState)·x[k] + B·gainRef·r
Taking only the first element of a min-norm sequence and recomputing does not preserve the terminal condition. For a scalar plant the closed-loop pole is Ad/(Ad²+1) — stable, geometric, not deadbeat — and the DC gain is:
$$\frac{A_d}{A_d^2 - A_d + 1} < 1$$
For Steps == 1 the same algebra gives a pole at the origin and DC gain exactly 1, which is why the defect is invisible there.
Reproduction
Scalar plant A = [Ad], B = [Bd], DeadbeatControl<float, 1, 1, 2>, simulated closed-loop to steady state with r = 1.0:
| Ad |
Steps |
Steady state |
Error |
| 0.9048 |
1 |
1.000000 |
0.00% |
| 0.9048 |
2 |
0.990091 |
0.99% |
| 0.8465 |
2 |
0.972912 |
2.71% |
| 0.6065 |
2 |
0.796653 |
20.33% |
The error grows as Ad decreases, i.e. as the plant gets faster relative to the sample rate.
Why the existing tests miss it
All three tests in TestDeadbeatControl.cpp assert open-loop N-step convergence from a fixed reference, or zero control when already at the reference. None checks closed-loop steady state, and none sweeps Ad. A regression test should simulate to steady state and assert x → r.
Suggested fix
Solve for a reference gain that makes the closed-loop DC gain unity (the standard N̄ precompensator) instead of taking the pseudo-inverse block:
B·gainRef = I - A + B·gainState
For the scalar case this reduces to gainRef = (1 - Ad + Bd·gainState) / Bd. Steps == 1 already satisfies it, so the change is backward compatible.
Caveat worth deciding on
This is not universally solvable. B·gainRef has rank ≤ InputSize, so exact unity DC gain on every state requires B to have full row rank. It works for StateSize == InputSize, but an under-actuated plant with Steps > 1 cannot achieve it exactly.
So there are two defensible resolutions:
- Apply the N̄ correction where
B has full row rank, and document the limitation otherwise.
- Declare that
DeadbeatControl is intended for open-loop application of the full N-step sequence, and add an API that exposes the whole sequence — making the current receding-horizon use of ComputeControl the misuse.
Option 2 is arguably more faithful to what the min-norm solution actually computes, but it conflicts with ComputeControl being the StateFeedbackController interface method, which strongly implies per-sample feedback use.
Context
Found while adopting DeadbeatControl for the PMSM current loop in the downstream e-foc project (per-axis scalar RL plant, Ad = e^(-Rs·Ts/Ls), 20 kHz). We are currently shipping Steps = 1 only; the two-step variant is intended for low-inductance motors, which is exactly the small-Ad regime where the error is largest.
Summary
controllers::DeadbeatControlwithSteps > 1never reaches its reference when used in receding-horizon mode (the modeComputeControlimplies). The closed loop settles to a fraction of the commanded value.Steps == 1is correct and unaffected.Introduced in #271.
Root cause
ComputeGainsassigns the reference gain directly from the pseudo-inverse block:This is the minimum-norm solution for the whole input sequence
[u[k] … u[k+N-1]], and it is correct if that entire sequence is applied open-loop — the state then reaches the reference in exactly N steps.But
ComputeControlreturns only the first element and is called again every sample, so the effective closed loop is:Taking only the first element of a min-norm sequence and recomputing does not preserve the terminal condition. For a scalar plant the closed-loop pole is
Ad/(Ad²+1)— stable, geometric, not deadbeat — and the DC gain is:For
Steps == 1the same algebra gives a pole at the origin and DC gain exactly 1, which is why the defect is invisible there.Reproduction
Scalar plant
A = [Ad],B = [Bd],DeadbeatControl<float, 1, 1, 2>, simulated closed-loop to steady state withr = 1.0:The error grows as
Addecreases, i.e. as the plant gets faster relative to the sample rate.Why the existing tests miss it
All three tests in
TestDeadbeatControl.cppassert open-loop N-step convergence from a fixed reference, or zero control when already at the reference. None checks closed-loop steady state, and none sweepsAd. A regression test should simulate to steady state and assertx → r.Suggested fix
Solve for a reference gain that makes the closed-loop DC gain unity (the standard N̄ precompensator) instead of taking the pseudo-inverse block:
For the scalar case this reduces to
gainRef = (1 - Ad + Bd·gainState) / Bd.Steps == 1already satisfies it, so the change is backward compatible.Caveat worth deciding on
This is not universally solvable.
B·gainRefhas rank ≤InputSize, so exact unity DC gain on every state requiresBto have full row rank. It works forStateSize == InputSize, but an under-actuated plant withSteps > 1cannot achieve it exactly.So there are two defensible resolutions:
Bhas full row rank, and document the limitation otherwise.DeadbeatControlis intended for open-loop application of the full N-step sequence, and add an API that exposes the whole sequence — making the current receding-horizon use ofComputeControlthe misuse.Option 2 is arguably more faithful to what the min-norm solution actually computes, but it conflicts with
ComputeControlbeing theStateFeedbackControllerinterface method, which strongly implies per-sample feedback use.Context
Found while adopting
DeadbeatControlfor the PMSM current loop in the downstreame-focproject (per-axis scalar RL plant,Ad = e^(-Rs·Ts/Ls), 20 kHz). We are currently shippingSteps = 1only; the two-step variant is intended for low-inductance motors, which is exactly the small-Adregime where the error is largest.