PID Deployment
This guide is for integrators who build a loop from Pid,
or who tune the loops the cascade builds from it. It
covers what to set, how to wire the controller into a loop, and what it
does at the edges. The developer guide has the control law
and the API.
Configuration
Section titled “Configuration”PidConfig<Real> is a plain aggregate. pid_config_valid() (I1) must hold
before a controller is built: non-negative gains, min <= max for both
limit pairs, and derivative_tau >= 0. The output’s unit is the loop’s
choice. The gains take whatever units make , , and
come out in it.
| Field | Unit | Default | What it sets |
|---|---|---|---|
kp | output / error | 0 | Proportional gain |
ki | output / (error·s) | 0 | Integral gain |
kd | output·s / error | 0 | Derivative gain, on the measurement |
output_min, output_max | output | −1, 1 | Output saturation, and the trigger for anti-windup |
integral_min, integral_max | output | −1, 1 | Bounds on the integral term itself |
derivative_tau | s | 0 | Time constant of the low-pass on the measured rate; 0 is no filter |
The defaults describe a normalized command with no gains. They are not a
tuning. The cascade derives its gains from the vehicle in
cascade_config_from(); for RP-1 they are:
| Loop | k_p | k_i | k_d | Output limit | Integral limit | τ |
|---|---|---|---|---|---|---|
| Rate, roll and pitch (rad/s → rad/s²) | 50 /s | 250 /s² | 0.4 | ±622 rad/s² | ±156 rad/s² | 2 ms |
| Rate, yaw | 50 /s | 250 /s² | 0.4 | ±76.8 rad/s² | ±19.2 rad/s² | 2 ms |
| Velocity, each axis (m/s → m/s²) | 3.57 /s | 1.28 /s² | 0 | ±9.81 m/s² | ±4.90 m/s² | 0 |
The rate loop’s output limit is the vehicle’s angular acceleration at full differential thrust, and its integral may use a quarter of that. The velocity loop’s output limit is one g.
Choosing the limits
Section titled “Choosing the limits”Output limits are the actuator’s authority, in the output’s unit. Set them to what the stage after the controller can deliver. Anti-windup engages at these limits, so limits wider than the actuator turn it off: the explainer shows what that costs.
Integral limits bound the disturbance the loop can cancel without a steady error. At rest, the integral supplies whatever the plant needs that the setpoint does not: a heavier vehicle, a trim offset, a steady wind. A narrow range keeps a wound-up integral from taking over the loop. Too narrow, and the loop settles with an error.
Keys (with the player focused): Space play/pause · ← → one sample (Shift: ten) · Home/End · [ ] speed
The height loop of the explainer (z'' = u + d; k_p = 16, k_i = 8, k_d = 8) holds 1 m, with the integral limited to ±2 m/s². At 1 s the offset d steps to −1.5 m/s². The integral grows to 1.5, within its limit, and the height dips to 0.92 m and returns to 1 m. At 6 s the offset steps to −3 m/s². The integral climbs to its limit of 2 (dashed) at 8 s and stops there. The P term has to carry the remaining 1 m/s², which it does with an error of 1/16 m, so the height settles at 0.9375 m. Pass criterion: the height at 12 s within 1 mm of the closed-form r + (d + I_max)/k_p.
- offset −1.5 m/s², within the integral's ±2
- offset −3 m/s², beyond it
- ✓ passBB-V1 height_error_vs_closed_form_offset = 4.04e-9 m (<= 0.001), computed by the scenario harness
Size the integral range for the largest steady disturbance you expect, with margin, and no wider. The cascade gives its rate loop a quarter of the output range, and its velocity loop half a g.
External saturation
Section titled “External saturation”A stage after the controller can deliver less than the controller asked
for: the mixer when the rotors run out
of authority, or a thrust limiter. The controller’s own limits do not see
that cut, so without help its integral winds up behind it. The
four-argument update(setpoint, measurement, dt, external_saturation)
takes the direction of the last cut, +1 when the stage delivered less than
the output, −1 when it delivered more, and 0 when it did not cut. The
integral then freezes by the same rule as at the controller’s own limits.
int cut = 0; // the stage's report from the previous tickfor (;;) { const double u = pid.update(setpoint, measurement, dt, cut); const double delivered = stage.apply(u); cut = delivered < u ? 1 : (delivered > u ? -1 : 0);}The cascade does exactly this with the mixer’s report, through
Cascade::feedback().
Keys (with the player focused): Space play/pause · ← → one sample (Shift: ten) · Home/End · [ ] speed
The windup loop of the explainer (T·x' = −x + u, T = 0.1 s; PI with k_p = 1, k_i = 10; output and integral limits ±1), but a stage after the controller delivers at most ±0.2 (dashed), five times less than the controller's own limit. The setpoint is 0.3, beyond what the stage delivers, for 2 s. The reported run's integral parks at 0.1, where its command just meets the cut. The unreported run's integral winds up to 0.9 behind the cut, while its command climbs to 1. At 2 s the setpoint drops to 0.1. The reported run's command drops below the cut at once, and the output is within 2 % for good after 0.54 s. The unreported run's command must unwind to the cut first, so its output stays at 0.2, and it settles only after 1.19 s. Pass criterion: the reported run's integral parks within 1e-3, one tick of integration, of cut − k_p·(r − cut) = 0.1.
- setpoint 0.3, beyond the 0.2 the stage delivers
- setpoint 0.1
- reported: within 2 % from here, 0.54 s after the drop
- not reported: within 2 % from here, 1.19 s after the drop
- ✓ passBB-V1 reported_integral_vs_park_value = 2.44e-4 (<= 0.001), computed by the scenario harness
Report the cut from the tick before. The controller’s output for this tick
does not exist until update() returns, so the stage’s report for it can
only be used on the next one.
The derivative filter and the loop rate
Section titled “The derivative filter and the loop rate”The filter is a first-order low-pass on the measured rate, with corner frequency : 80 Hz at the cascade’s τ = 2 ms. Two bounds apply.
- Below: τ of at least a loop period or two, or the filter passes the sample-to-sample noise through. The cascade’s 2 ms is two periods of its 1 kHz rate loop.
- Above: τ small against the loop’s own time scale, or the damping arrives late and the loop overshoots. The explainer’s widget shows the overshoot doubling once τ reaches a few times that scale.
derivative_tau = 0 takes the raw finite difference, which is right only
for a clean measurement or no D term.
Timing
Section titled “Timing”- Pass the measured dt.
update()integrates and differentiates with thedtit is given, so pass the time since the last call, not the nominal period. - A non-positive dt is a fault.
update()returns the last output and leaves its state alone, including the measurement of the last accepted call. The next call must then pass the time since that accepted call, not since the rejected one, or the derivative divides by too short an interval and spikes. Takedtfrom a monotonic clock, and after a clock discontinuity,reset()rather than guess an interval. - A long dt is accepted. After a missed deadline, the integral takes the whole gap in one step. The controller cannot tell a late tick from a slow loop, so detect overruns in the scheduler. The developer guide replays each case.
- Cost. About 12–13 ns per update on x86, for float and double alike
(
bench/pid_bench, GCC 15.2 Release, i7-12700K). The operation count is fixed, with no calls (tests/sdk/pid_resource.cpp): 12 arithmetic operations per update, 17 with the derivative filter. The first update after construction orreset()has no rate to difference and runs 10, or 15 filtered, and a rejected dt runs none. Measure on the flight processor before budgeting.
Lifecycle
Section titled “Lifecycle”- Gains are fixed at construction. There is no setter. To retune, build
a new controller and go through the lifecycle
(
Unconfigured → Inactive → Active). - Reset on arming and on a mode switch.
reset()zeroes the integral and the rate, and unprimes the derivative, so the first update after it has no derivative term. - No bumpless transfer.
reset()sets the integral to zero, or to the nearer integral bound when zero is outside them. The first update after it integrates before it computes its output, so that output is the P term plus one step of integration, , plus the reset integral. With , , , and = 0.1 s that is 0.5 + 0.5 = 1. A loop taking over from another at a nonzero command steps at the switch, and Pid has no way to preload its integral.
Scalar types
Section titled “Scalar types”Pid uses only + - * /, <, and construction from an integer. The test
suite runs it in double, float, posit<32,2>, posit<16,1>, and
cfloat<32,8>. In closed loop the 32-bit types stay within 1e-5 of double,
and posit<16,1> within 1e-2, on a loop with outputs of order 1. Scale the
loop so that its signals sit near 1 when you use a 16-bit type.
Consumer contract
Section titled “Consumer contract”| Preconditions | pid_config_valid(cfg) at construction; finite inputs |
| Postconditions | The output is within [output_min, output_max], and the integral within its bounds, after every call |
| Faults | dt ≤ 0 returns the last output with the state unchanged |
| State | The integral, the filtered rate, the last measurement and output, and whether it is primed; reset() returns to the constructed state |
| Resources | noexcept; no allocation or system calls; constant time; usable in constexpr |
| Not provided | Setpoint weighting, feed-forward, gain scheduling, bumpless transfer, dt validation beyond the sign |