The PID control law as a composable Nx.Defn kernel.
step/4 is written elementwise over its tensors, so the same kernel drives one
loop or a thousand. Gains and state are both tensors of the same shape: {}
for a single loop, {n} for n loops advanced in one call, and any shape
beyond that if you have a reason. Nothing here reshapes or branches per loop.
This is why the law lives in a defn rather than in the GenServer that
usually calls it - it can be JIT-compiled, vectorised over a batch axis, and
composed into a larger computation, none of which is possible for a control
law locked behind a process. BB.PID.Controller is just the {} case.
Usage
kernel = BB.PID.Kernel.new(kp: 2.0, ki: 0.5, kd: 0.1)
kernel = BB.PID.Kernel.step(kernel, _setpoint = 1.0, _measurement = 0.0, _dt = 0.01)
BB.PID.Kernel.output(kernel)Batched, with per-loop gains:
kernel = BB.PID.Kernel.new(kp: [2.0, 1.0], ki: [0.5, 0.0], kd: [0.0, 0.1])
kernel =
BB.PID.Kernel.step(
kernel,
Nx.tensor([1.0, 0.5]),
Nx.tensor([0.0, 0.4]),
0.01
)The control law
Two deliberate departures from the textbook form, both of which the
pid_control package this replaced got wrong.
The derivative acts on the measurement, not the error. With e = sp - pv,
differentiating the error means a step change in setpoint puts a spike of
kd * Δsp / dt through the output - "derivative kick". Differentiating pv
and negating gives the same response to disturbances with no sensitivity to
setpoint changes at all.
Anti-windup is by back-calculation. When the output saturates, the saturation error is folded straight back into the integrator:
integral = unsaturated_integral + (output - unsaturated_output)which holds p + i + d == output exactly while clamped, so the integrator
stops accumulating the instant the output hits a limit and starts moving again
the instant the error reverses. Clamping the integrator to the output range
instead - as pid_control did - bounds the wrong quantity: with a large kp
the proportional term alone can fill the range, leaving the integrator free to
wind up to a limit it then has to unwind before the output responds at all.
Precision
Everything the kernel builds is :f64, and numbers and lists handed to
step/4 are converted to :f64 too - so step(kernel, [0.3, 0.9], ...) is
exact where Nx.tensor([0.3, 0.9]) would not be. This has to happen at the
boundary: defn converts a plain float argument to a tensor at the Nx
default of :f32 on entry, and casting inside the defn preserves that
rounding rather than undoing it. Hence the deftransform, which also keeps
step/4 callable from within another defn.
The one lossy path left is handing in a tensor you built yourself at :f32.
It is upcast, but the precision went when the tensor was created. Build batched
inputs as lists, or as Nx.tensor(values, type: :f64).
Gains are per second
ki and kd are applied against the measured dt handed to step/4, so
they are gains per second of accumulated error and per second of measurement
change respectively. They do not depend on the loop's rate.
The first step
A loop has no previous measurement to differentiate on its first step, so the
derivative term is held at zero until one has been recorded. The :primed
field tracks this per loop, which matters under batching - loops added to a
batch at different times each prime on their own first step.
Summary
Functions
Build a kernel from gains.
The most recent output as a number, for a single-loop kernel.
Replace the gains, leaving accumulated loop state intact.
Advance the loop by one step over the elapsed time dt, in seconds.
Types
Functions
Build a kernel from gains.
Each gain accepts a number for a single loop, or a list (or tensor) to drive a batch. All gains must agree on shape. State fields are zeroed to match.
Options
:kp(required) - proportional gain.:ki- integral gain per second. Default0.0.:kd- derivative gain per second. Default0.0.:tau- low-pass coefficient applied to the derivative term, in0..1.1.0(the default) is unfiltered; lower values attenuate measurement noise at the cost of lag.:output_min/:output_max- output clamp. Defaults-1.0/1.0.
The most recent output as a number, for a single-loop kernel.
Batched kernels should read kernel.output as a tensor rather than forcing it
through a scalar; Nx.to_number/1 on a non-scalar raises.
Replace the gains, leaving accumulated loop state intact.
Retuning mid-flight must not discard the integrator: dropping it steps the
output by whatever the integral had accumulated, which on a loaded joint is a
visible jolt. Only the gains named in opts change.
Advance the loop by one step over the elapsed time dt, in seconds.
setpoint, measurement and dt each accept a number, a list, or a tensor,
and broadcast against the kernel's shape - so a batch may share a setpoint or a
dt while carrying its own measurements. Numbers and lists are converted to
:f64; see the precision note above.