Skip to content

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.

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 KpeK_p e, Ki∫e dtK_i \int e\,dt, and Kdy˙K_d \dot y come out in it.

FieldUnitDefaultWhat it sets
kpoutput / error0Proportional gain
kioutput / (error·s)0Integral gain
kdoutput·s / error0Derivative gain, on the measurement
output_min, output_maxoutput−1, 1Output saturation, and the trigger for anti-windup
integral_min, integral_maxoutput−1, 1Bounds on the integral term itself
derivative_taus0Time 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:

Loopk_pk_ik_dOutput limitIntegral limitτ
Rate, roll and pitch (rad/s → rad/s²)50 /s250 /s²0.4±622 rad/s²±156 rad/s²2 ms
Rate, yaw50 /s250 /s²0.4±76.8 rad/s²±19.2 rad/s²2 ms
Velocity, each axis (m/s → m/s²)3.57 /s1.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.

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.

How large a disturbance can the integral cancel? 1× real time
Height [m] z setpoint closed form
Integral term [m/s²] I −d, the integral needed integral limit
t = 0.000 s / 12.000 s

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.

  1. offset −1.5 m/s², within the integral's ±2
  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.

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 tick
for (;;) {
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().

What happens when a downstream cut is not reported? 1× real time
Plant output reported not reported setpoint
Controller command reported not reported stage limit
Integral term reported not reported
t = 0.000 s / 5.000 s

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.

  1. setpoint 0.3, beyond the 0.2 the stage delivers
  2. setpoint 0.1
  3. reported: within 2 % from here, 0.54 s after the drop
  4. 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 filter is a first-order low-pass on the measured rate, with corner frequency 1/(2πτ)1/(2\pi\tau): 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.

  • Pass the measured dt. update() integrates and differentiates with the dt it 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. Take dt from 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 or reset() has no rate to difference and runs 10, or 15 filtered, and a rejected dt runs none. Measure on the flight processor before budgeting.
  • 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, Kie ΔtK_i e\,\Delta t, plus the reset integral. With Kp=1K_p = 1, Ki=10K_i = 10, e=0.5e = 0.5, and Δt\Delta t = 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.

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.

Preconditionspid_config_valid(cfg) at construction; finite inputs
PostconditionsThe output is within [output_min, output_max], and the integral within its bounds, after every call
Faultsdt ≤ 0 returns the last output with the state unchanged
StateThe integral, the filtered rate, the last measurement and output, and whether it is primed; reset() returns to the constructed state
Resourcesnoexcept; no allocation or system calls; constant time; usable in constexpr
Not providedSetpoint weighting, feed-forward, gain scheduling, bumpless transfer, dt validation beyond the sign