Skip to content

Implement DeadbeatControl #269

Description

@gabrielfrasantos

Task Brief — DeadbeatControl in numerical-toolbox

Target repository: numerical-toolbox (this brief is written for an agent working inside that
repo; e-foc is the downstream consumer and is out of scope).

Goal: implement a generic discrete-time deadbeat (plant-inversion) controller so downstream
projects stop hand-rolling it.

Authoritative conventions: AGENTS.md at the toolbox root. This brief does not restate it —
follow it. The points below are only the ones where this algorithm has a specific choice to make.


1. Why

Deadbeat control is the only mainstream discrete controller family the toolbox lacks. It already
ships LQR, LQI, LQG, MPC, Luenberger, sliding-mode, ADRC and backstepping. The single existing
mention of "deadbeat" is a caveat in doc/controllers/LuenbergerObserver.md about deadbeat
observer pole placement — that is state estimation, not control, and is not an implementation.

Downstream, e-foc currently hand-rolls a scalar deadbeat current controller because no toolbox type
exists. Its two sibling algorithms both come from the toolbox (PidIncremental,
SlidingModeControl), so the local implementation is an inconsistency to be removed.


2. Mathematical foundation

Discrete LTI plant:

$$ x[k+1] = A_d,x[k] + B_d,u[k] $$

One-step law — set $x[k+1] = r$ and solve for $u$:

$$ u[k] = (B_d)^{+},\bigl(r - A_d,x[k]\bigr) $$

For $\text{InputSize} < \text{StateSize}$, $B_d$ is not square and the one-step law is only solvable
when the reference lies in the reachable subspace. See §3 on how to handle this.

N-step law — reaching the reference in $N$ samples distributes the correction and reduces noise
amplification. For the SISO case this generalises to:

$$ u[k] = \frac{r - (A_d)^N,x[k]}{B_d \sum_{i=0}^{N-1}(A_d)^i} $$

with $N = 1$ collapsing to the one-step law. The $N = 2$ case is the variant most often used in
practice, because the one-step law amplifies measurement noise by $\lVert B_d^{-1}\rVert$, which is
large whenever the input gain is small.

Reachability: the law requires the pair $(A_d, B_d)$ to be reachable in $N$ steps. An
unreachable plant makes the inversion singular. control_analysis::ControllabilityObservability
already provides IsControllable; reuse it, do not reimplement a rank test.

Settling: exactly $N$ samples in the absence of model error and saturation. A relative model
error $\varepsilon$ in $B_d$ leaves a residual error of order $\varepsilon$ after $N$ steps — this
is the algorithm's defining weakness and must be stated in the doc.


3. Decisions to make (state your choice in the PR description)

These are genuine design choices, not oversights. Pick one and justify it briefly.

# Question Options
1 Support general MIMO, or restrict to InputSize == 1? Restricting keeps the solve well-posed and covers the known consumers. Generalising requires a pseudo-inverse and a documented reachable-subspace contract.
2 Is $N$ a template parameter or a constructor argument? Template = gains foldable at compile time, no runtime branch. Constructor = reconfigurable without recompiling.
3 Fail-closed behaviour on an unreachable or singular plant Options: really_assert, a factory returning std::optional, or an inert zero-gain instance. The third is what e-foc needed downstream — an unconfigured controller must output exactly zero, never a fallback control law.
4 Does the type own output saturation? The toolbox already has SaturationRateLimiter; composing is probably better than duplicating.

Prefer the option that keeps ComputeControl free of branches and divisions — the intended callers
run it in a 20 kHz ISR.


4. Suggested API

Follow the shape of Lqr — precompute everything in the constructor, leave only the gain
application in the hot path.

  • Inherit controllers::StateFeedbackController<T, StateSize, InputSize> so the type composes with
    the existing GainScheduledController / Feedforward2Dof wrappers.
  • Constructor overloads mirroring Lqr: one taking A/B, one taking
    math::LinearTimeInvariant, and one taking a precomputed gain.
  • Reference tracking needs a two-argument entry point; the base class only declares
    ComputeControl(state). Decide whether the reference is a constructor/setter value or a second
    ComputeControl argument — SlidingModeControl already established the two-argument overload
    precedent.
  • Expose the precomputed gains through a [[nodiscard]] getter so tests can assert on them
    directly rather than only through closed-loop behaviour.

Reuse, do not reimplement: math::LinearTimeInvariant, solvers::SolveSystem
(numerical/solvers/GaussianElimination.hpp), control_analysis::ControllabilityObservability.


5. Deliverables

File Notes
numerical/controllers/implementations/DeadbeatControl.hpp Implementation
numerical/controllers/implementations/DeadbeatControl.cpp Explicit instantiations, coverage build only
numerical/controllers/implementations/test/TestDeadbeatControl.cpp Tests
numerical/controllers/implementations/CMakeLists.txt Add to target_sources and numerical_add_coverage_sources
numerical/controllers/implementations/test/CMakeLists.txt Add test source
doc/controllers/DeadbeatControl.md Per doc/TEMPLATE.md
doc/controllers/README.md Add the algorithm row

Float-only per AGENTS.md — generic template<typename T> with
static_assert(std::is_floating_point_v<T>, ...), but instantiate and test float only. Do not
add Q15/Q31. Note that Lqr.cpp does instantiate them; that is legacy and is not the pattern to
copy here.


6. Test cases

One behaviour per test, TEST_F on float, EXPECT_NEAR with math::Tolerance<float>(). Tests
call std:: cmath directly, never math::.

  1. First-order plant reaches the reference in exactly one step (simulate the plant forward and assert
    the state, not just the gain).
  2. Two-step variant reaches the reference in exactly two steps.
  3. Two-step input gain is strictly smaller than one-step — this is the noise-amplification claim, and
    it is the reason the variant exists.
  4. Zero error at the reference still commands the input needed to hold it against plant decay
    (a deadbeat controller is not a pure error regulator; this catches a sign/structure error that
    the other tests pass through).
  5. Computed gain matches the closed-form $1/B_d$ and $A_d/B_d$ for a known scalar plant.
  6. Whatever fail-closed behaviour decision feat: add clarke and park transforms #3 selects, asserted directly.
  7. If MIMO is supported: a reachable 2-state / 1-input plant converges in 2 steps.

No redundant tests. Coverage ≥ 90 %.


7. Documentation

doc/controllers/DeadbeatControl.md per doc/TEMPLATE.md — math, theory, complexity, pitfalls.
No code, no class names, no usage examples.

Must cover:

  • Both the one-step and N-step laws, with the noise-amplification tradeoff quantified.
  • The reachability precondition and what happens when it fails.
  • Sensitivity to model error: a relative error in $B_d$ maps to a proportional residual after
    settling. Deadbeat is the least robust controller in the library and the doc must say so plainly,
    alongside the pointer that sliding-mode is the robust alternative for the same plant.
  • Behaviour under input saturation: settling extends beyond $N$ samples but remains time-optimal for
    the available authority.

8. Acceptance criteria

  • cmake --preset host && cmake --build --preset host clean, no new warnings
  • ctest --preset host green
  • Coverage ≥ 90 % on the new files
  • .clang-format clean
  • Decisions from §3 stated in the PR description

9. Downstream context (informational — do not implement here)

e-foc will adopt this in DeadbeatCurrentController, which controls the d- and q-axis currents of a
PMSM. Its plant is a scalar RL circuit per axis, discretized as
$A_d = e^{-R_s T_s/L_s}$, $B_d = (1-A_d)/R_s$, with both axes sharing identical dynamics. It runs at
20 kHz in an ISR with a budget of roughly 400 cycles for the entire FOC chain, and it needs the
one-step/two-step variant choice exposed at runtime.

Be aware of the likely outcome: after precomputing gains, the scalar case reduces to two multiply-
accumulates, so e-foc may well keep its specialised version regardless. This type should earn its
place as a general library algorithm — do not contort the API to fit that one consumer.

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