You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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::.
First-order plant reaches the reference in exactly one step (simulate the plant forward and assert
the state, not just the gain).
Two-step variant reaches the reference in exactly two steps.
Two-step input gain is strictly smaller than one-step — this is the noise-amplification claim, and
it is the reason the variant exists.
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).
Computed gain matches the closed-form $1/B_d$ and $A_d/B_d$ for a known scalar plant.
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.
Task Brief —
DeadbeatControlin numerical-toolboxTarget repository:
numerical-toolbox(this brief is written for an agent working inside thatrepo; 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.mdat 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.mdabout deadbeatobserver 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:
One-step law — set$x[k+1] = r$ and solve for $u$ :
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:
with$N = 1$ collapsing to the one-step law. The $N = 2$ case is the variant most often used in$\lVert B_d^{-1}\rVert$ , which is
practice, because the one-step law amplifies measurement noise by
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::ControllabilityObservabilityalready 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$\varepsilon$ in $B_d$ leaves a residual error of order $\varepsilon$ after $N$ steps — this
error
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.
InputSize == 1?really_assert, a factory returningstd::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.SaturationRateLimiter; composing is probably better than duplicating.Prefer the option that keeps
ComputeControlfree of branches and divisions — the intended callersrun it in a 20 kHz ISR.
4. Suggested API
Follow the shape of
Lqr— precompute everything in the constructor, leave only the gainapplication in the hot path.
controllers::StateFeedbackController<T, StateSize, InputSize>so the type composes withthe existing
GainScheduledController/Feedforward2Dofwrappers.Lqr: one takingA/B, one takingmath::LinearTimeInvariant, and one taking a precomputed gain.ComputeControl(state). Decide whether the reference is a constructor/setter value or a secondComputeControlargument —SlidingModeControlalready established the two-argument overloadprecedent.
[[nodiscard]]getter so tests can assert on themdirectly rather than only through closed-loop behaviour.
Reuse, do not reimplement:
math::LinearTimeInvariant,solvers::SolveSystem(
numerical/solvers/GaussianElimination.hpp),control_analysis::ControllabilityObservability.5. Deliverables
numerical/controllers/implementations/DeadbeatControl.hppnumerical/controllers/implementations/DeadbeatControl.cppnumerical/controllers/implementations/test/TestDeadbeatControl.cppnumerical/controllers/implementations/CMakeLists.txttarget_sourcesandnumerical_add_coverage_sourcesnumerical/controllers/implementations/test/CMakeLists.txtdoc/controllers/DeadbeatControl.mddoc/TEMPLATE.mddoc/controllers/README.mdFloat-only per
AGENTS.md— generictemplate<typename T>withstatic_assert(std::is_floating_point_v<T>, ...), but instantiate and testfloatonly. Do notadd
Q15/Q31. Note thatLqr.cppdoes instantiate them; that is legacy and is not the pattern tocopy here.
6. Test cases
One behaviour per test,
TEST_Fonfloat,EXPECT_NEARwithmath::Tolerance<float>(). Testscall
std::cmath directly, nevermath::.the state, not just the gain).
it is the reason the variant exists.
(a deadbeat controller is not a pure error regulator; this catches a sign/structure error that
the other tests pass through).
No redundant tests. Coverage ≥ 90 %.
7. Documentation
doc/controllers/DeadbeatControl.mdperdoc/TEMPLATE.md— math, theory, complexity, pitfalls.No code, no class names, no usage examples.
Must cover:
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.
the available authority.
8. Acceptance criteria
cmake --preset host && cmake --build --preset hostclean, no new warningsctest --preset hostgreen.clang-formatclean9. Downstream context (informational — do not implement here)
e-foc will adopt this in
$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
DeadbeatCurrentController, which controls the d- and q-axis currents of aPMSM. Its plant is a scalar RL circuit per axis, discretized as
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.