PID
PID closes a control loop: it takes an error and produces a control output from a proportional, an integral and a derivative term.14 minute read
PID closes a control loop: it takes an error and produces a control output
from a proportional, an integral and a derivative term. Use it wherever a loop
is closed outside the actuator control loop — a pressure loop, a temperature
loop, a force loop.
Three things distinguish it from a textbook PID. Its integral only accumulates while the error is large enough and the machine is slow enough. It has three independent protections against integral wind-up. And its derivative is filtered, so sensor noise is not amplified without bound. It is single channel: one instance closes one loop.
flowchart LR
i1(["error — reference minus actual"]) --> B["PID"]
i2(["actual — feeds the velocity gate only"]) --> B
i3(["iReset, integratorFreeze, controllerReset"]) --> B
i4(["disable"]) --> B
B --> o1(["output — the control signal"])
B --> o2(["iTerm — integral contribution"])
B --> o3(["dTerm — derivative contribution"])
B --> o4(["isEnabled"])
$u = k_p e + k_i !\int! e,dt + k_d \frac{de}{dt}$.
ndis the derivative filter frequency in rad/s: the D action rolls off above it, and its gain at high frequency is capped at $k_d \times$nd. Keepndbelow $2/$ task period [s] — 2000 on a 1 ms task — and above 0. The integral term reachesoutputone cycle after it is accumulated.
enable defaults to true, so the block runs unless you switch it off — the
default is deliberate, so that adding the parameter did not silently mute every
existing PID. While it is off, or the disable input is on, the output is zero
and the whole state is held cleared, so re-enabling starts from rest rather
than dumping a stale integral into the plant.
iMax and iMin default to ±infinity, so there is no integrator clamp
unless you configure one. Wind-up is held off by the output saturation, the
directional conditional integration and backCalculationGain; the clamp is a
hard backstop on top of those. This changed: they used to default to ±0.1,
which on any real machine looked like a broken ki rather than a backstop. If
you were relying on that ±0.1, set it explicitly.
Signals
Inputs
| Path | Unit | Range | Description |
|---|---|---|---|
error |
signal unit | unbounded | The control error: reference minus actual. A single value, not an array. This is the only input the control law acts on. |
actual |
signal unit | unbounded | The measured value. It feeds the velocity gate only and plays no part in computing the output. Leave it at 0 if you are not using the velocity window. |
iReset |
- | - | While true, the integral term is held at zero and stops accumulating, from the same cycle you raise it. The block becomes a proportional-derivative controller for as long as you hold it. It is a level, not a pulse. It does not clear dTerm. |
disable |
- | - | While true the output is zero and the integral term, derivative term and error history are held cleared, so re-enabling starts from rest and cannot dump a stale integral into the plant. The error and actual history keep tracking the live inputs while disabled, so re-enabling produces no derivative spike and no spurious velocity-gate trip. Use this for a runtime override from a supervisor; use enable for the configured intent. It is a level, not a pulse. |
integratorFreeze |
- | - | While true the integral term holds its value — it keeps contributing to the output but stops accumulating. This is the difference from iReset, which discharges it to zero. A level, not a pulse. |
controllerReset |
- | - | While true the whole controller state is cleared — integral term, derivative term and error history — at the start of every cycle. Broader than iReset, which touches the integral term only. It does not suppress the output: the controller runs on from the zeroed history, so output is the proportional term plus a derivative formed against a zero previous error. That is the difference from disable, which holds the output at zero. A level, not a pulse. |
Outputs
| Path | Unit | Description |
|---|---|---|
output |
output unit | The control signal, after the output limits are applied. A single value. Starts from zero after every controller start, but the integral state is not cleared by a stop — see Limits and errors. |
iTerm |
output unit | The integral term’s accumulated value. It is published one cycle ahead of its effect on output, so the two will not reconcile sample-for-sample on a trace. Watch it to see wind-up as it happens. |
dTerm |
output unit | The filtered derivative term. Not cleared by iReset, and not cleared by a stop. Is cleared by disable, enable = false and controllerReset. |
isEnabled |
- | True when enable is true and the disable input is false. While it is false the output is zero and the state is held cleared. |
Parameters
| Path | Unit | Default | Range | Effect |
|---|---|---|---|---|
enable |
- | true | - | Runs the controller. While false the output is zero and the state is held cleared, exactly as for the disable input. Defaults to true, so a PID that never writes this parameter behaves as it did before the parameter existed — note the actuator loop’s inline PID controller defaults the other way and relies on its configuration to switch it on. |
kp |
output unit per signal unit | 0.0 | any | Proportional gain. Raising it makes the loop stiffer and faster, and eventually makes it oscillate. |
ki |
output unit per signal unit per second | 0.0 | any | Integral gain. It removes steady-state error. It does nothing useful until iMax and iMin are set wide enough — see below. |
kd |
output unit per signal unit per second | 0.0 | any | Derivative gain. It damps overshoot and amplifies noise. Set nd before you use it. |
nd |
rad/s | 1.0 | clamped to [0, 2/task period [s]] — stay strictly below the ceiling | Derivative filter frequency. Higher gives a sharper, noisier D action; lower gives a softer, slower one. Clamped every cycle to the filter’s stability bound, so a value that would make the derivative diverge is refused rather than accepted — 2000 is the ceiling on a 1 ms task. The clamp is silent; read the leaf back to see the applied value, and expect a warning in the log at startup if your configured value was above the bound. At exactly the ceiling the filter pole sits at −1, so dTerm alternates sign without decaying — treat the bound as a limit to stay under, not a setting. At the default of 1.0 the D action is filtered so heavily that kd barely acts. |
iPositionErrorThresholdPos |
signal unit | 0.0 | any | The integral accumulates only while the error is above this or below the negative threshold. At the default of 0 it accumulates whenever the error is not exactly zero. |
iPositionErrorThresholdNeg |
signal unit | 0.0 | any | The lower half of the same gate. |
iActualVelocityWindowPos |
signal unit per second | +infinity | any | The integral accumulates only while the rate of change of actual is inside this window. The default admits everything. |
iActualVelocityWindowNeg |
signal unit per second | −infinity | any | The lower half of the same window. Writing 0 to both closes the gate permanently and the integral never accumulates. |
iMax |
output unit | +infinity | any | Hard upper bound on the integral term. At the default there is no bound. If written below iMin, the two are swapped. |
iMin |
output unit | −infinity | any | Hard lower bound. At the default there is no bound. Note the C++ setter forces the sign (setIMin(5) stores −5), so through the API the window always brackets zero; a direct parameter write does not. |
controlOutputMin |
output unit | −infinity | must be less than controlOutputMax |
Lower output limit. Infinity means no limit, which is the legacy behaviour. Set this and its pair before any gain. |
controlOutputMax |
output unit | +infinity | must be greater than controlOutputMin |
Upper output limit. |
backCalculationGain |
1/s | 0.0 | 0 upward | Second anti-windup layer. Above 0 it actively unwinds the integral while the output is saturated. 0 is off. Too large a value makes the integral oscillate. |
controlErrorDeadBand |
signal unit | 0.0 | 0 upward | Errors smaller than this do not accumulate in the integral. It applies to the integral path only — the proportional and derivative terms still see the full error. |
All fifteen parameters are persistent and survive a controller restart. The inputs and outputs do not. No parameters exist below this block.
Setup
-
Set
controlOutputMinandcontrolOutputMaxto what your actuator can accept. Do this first, before any gain.Step 1 is what makes the anti-windup work at all. With the limits left at infinity the block never knows it is saturated, so the directional anti-windup and
backCalculationGainboth have nothing to act on. -
Set
iMaxandiMinto the largest integral contribution you will accept — often a fraction of the output range. Leave them at ±0.1 and the integral will saturate at once. -
Set
kp,kiandkdto 0, andndto something sensible for your task rate — 50 to 200 rad/s is a normal starting range on a 1 ms task. -
Link
errorfrom your comparison. Linkactualonly if you intend to use the velocity window; otherwise leave it at 0. -
Raise
kpfrom 0 until the loop responds usefully.outputshould trackerror×kpexactly whilekiandkdare still 0. -
Raise
kifrom 0 until steady-state error disappears. WatchiTerm— if it sits pinned atiMaxoriMin, go back to step 2. -
Raise
kdfrom 0 only if you need to damp overshoot. WatchdTermon a trace; if it is noisy, lowernd. -
Drive the loop into saturation deliberately and confirm it comes back out promptly when the error reverses. If it hangs, see the symptom table.
Tuning
- Set the limits before the gains:
controlOutputMinandcontrolOutputMaxfirst, theniMinandiMax. Every anti-windup behaviour depends on them. - Tune
kpalone. Raise it until the response is fast enough, then back off until any oscillation is gone. Leavekiandkdat 0 throughout. - Add
kinext. Raise it until steady-state error is removed within an acceptable time. Too much shows as a slow oscillation the proportional gain cannot explain. - Add
kdlast, and only for overshoot. ReaddTermon a trace before you trust it — if the trace is noise,kdis amplifying your sensor and you should lowerndor leavekdat 0. - Set
ndagainst your noise, not against your response. Start at 100 rad/s on a 1 ms task and lower it untildTermis smooth. Never go above $2/$ task period and never to 0. - Use
controlErrorDeadBandonly to stop the integral hunting around a small residual error. It does not quiet the proportional or derivative terms. - Use the error thresholds and the velocity window only when the integral must be suppressed during motion — they exist to stop wind-up while the machine is moving fast. Leave them at their defaults otherwise.
- Turn on
backCalculationGainonly if the directional anti-windup alone is not getting you out of saturation fast enough. Start small; a large value drives the integral hard the other way. - Re-check
ndafter any task-rate change. Its upper limit scales with the task period.
Read each gain’s contribution off the gap between the curves.
| Symptom | Cause | Action |
|---|---|---|
| Steady-state error never goes away | ki is 0, or iTerm is pinned at iMax or iMin |
Raise ki; if iTerm is pinned, raise iMax and lower iMin first |
Raising ki seems to do nothing |
Expected with the ±0.1 default limits: the integral saturates almost immediately | Set iMax and iMin to real values for your output unit |
| The loop overshoots and rings | kp too high, or ki too high |
Lower kp first; if the oscillation is slow, lower ki |
| The output buzzes or is audibly rough | kd amplifying sensor noise |
Lower nd, or set kd to 0 |
dTerm is pure noise on a trace |
nd too high for the noise on error |
Lower nd |
dTerm froze at a value and never moves |
nd is 0 |
Set nd above 0 |
output or dTerm grew without bound |
nd above 2/task period, or negative |
Set nd within range, then restart the controller |
| The loop stays saturated long after the error reversed | Wind-up: the output limits are still at infinity, so the anti-windup has nothing to work against | Set controlOutputMin and controlOutputMax |
| Still slow to leave saturation with the limits set | Directional anti-windup alone is not enough for this loop | Raise backCalculationGain from 0, a little at a time |
| The integral oscillates after saturation | backCalculationGain too high |
Lower it, or return it to 0 |
A large kick on output on the first cycle after a start |
Expected with a non-zero kd: the derivative sees the initial error as a step |
Ramp the reference in, or start with the error near zero |
A full-scale output immediately after a restart |
Expected: the integral state is not cleared by a stop, so a loop that was saturated resumes saturated | Pulse iReset before releasing the loop |
iTerm and output do not add up on a trace |
Expected: iTerm is published one cycle ahead of its effect |
Compare iTerm against the next sample of output |
| The integral never accumulates at all | The velocity window is closed — writing 0 to both ends admits nothing | Set iActualVelocityWindowPos to +infinity and the negative one to −infinity |
| The integral stops accumulating whenever the machine moves | Expected if the velocity window is set: that is what it is for | Widen the window, or leave it at its defaults |
| The integral does not accumulate near the setpoint | controlErrorDeadBand or the error thresholds are set |
Reduce the dead band; remember it affects the integral only |
| Small errors still drive the proportional term after setting a dead band | Expected: the dead band applies to the integral path only | Use a dead-zone block upstream if you need to quiet the whole controller |
output and iTerm went to a non-numeric value and iReset did not clear it |
A non-numeric value reached the derivative path, which iReset does not clear |
Restart the controller, then fix the source |
| You need to close several loops | Not possible — this block is single channel | Use one instance per loop |
A conservative starting point for a loop on a 1 ms task, with the limits in the actuator’s own units:
controlOutputMin = -10.0
controlOutputMax = 10.0
iMin = -5.0
iMax = 5.0
nd = 100.0
kp = 1.0
ki = 0.0
kd = 0.0
backCalculationGain = 0.0
controlErrorDeadBand = 0.0
Work steps 2 to 4 of Tuning from there. This is a starting point, not a final tuning.
Limits and errors
| Limit | Set by | What happens | Reported |
|---|---|---|---|
iMin < iMax |
The block | Swapped if you invert them, rather than refused — the same thing Limiter does with an inverted window. The C++ setters also force the signs, so only a direct parameter write can invert it |
Not reported; read both leaves back |
controlOutputMin < controlOutputMax |
Nothing | Not checked. The same hazard, with no sign forcing at all | Not reported |
nd within 0 and 2/task period [s] — the filter’s stability bound |
The block | Clamped, silently, every cycle, at both ends: negative values become 0, values above the bound become the bound. The derivative can no longer be configured into divergence. At exactly the bound the pole is −1, so dTerm alternates without decaying — stay strictly below it. 2000 is the ceiling on a 1 ms task |
Silent in the RT path; read nd back. A warning is logged at startup when the configured value is above the bound |
output |
controlOutputMin, controlOutputMax |
Clamped to the range. At the infinite defaults it is unbounded, and the anti-windup is inactive | Not reported; compare output against your limits |
| Integral term | iMin, iMax, plus directional anti-windup and backCalculationGain |
Clamped where a clamp is configured — there is none at the default — and actively prevented from winding further into an active output limit | Not reported; watch iTerm |
| Integral state across a stop | Nothing | Not cleared by a stop or a start. A loop that was saturated when it stopped resumes saturated. Pulse iReset before releasing the loop, or hold disable, which does clear it |
Not reported |
| Derivative state | Nothing | Not cleared by iReset, a stop, or a start. disable, enable = false and controllerReset all clear it, and they seed the error history from the live error so re-enabling does not kick the derivative |
Not reported |
| Channel count | Fixed | One loop per instance, always | Not reported |
The only thing the block logs is the startup warning about nd exceeding its
stability bound. Every other failure above shows as a value on a trace, not as
a message.
Verified against motorcortex-control3 3.30.0 (bc348fd).