PID Controller
A reusable PID controller node that reads a setpoint and measured value, then publishes a control output. Includes integral anti-windup and derivative low-pass filtering.
horus.toml
[package]
name = "pid-controller"
version = "0.1.0"
description = "Generic PID with anti-windup and derivative filtering"
Complete Code
use horus::prelude::*;
/// Setpoint command: what we want
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, LogSummary)]
#[repr(C)]
struct Setpoint {
target: f32,
}
/// Measured feedback: what we have
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, LogSummary)]
#[repr(C)]
struct Measurement {
value: f32,
}
/// PID output: what to do
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, LogSummary)]
#[repr(C)]
struct ControlOutput {
command: f32,
error: f32,
p_term: f32,
i_term: f32,
d_term: f32,
}
// ── PID Node ────────────────────────────────────────────────
struct PidNode {
setpoint_sub: Topic<Setpoint>,
measurement_sub: Topic<Measurement>,
output_pub: Topic<ControlOutput>,
// PID gains
kp: f32,
ki: f32,
kd: f32,
// State
integral: f32,
prev_error: f32,
prev_derivative: f32,
// Limits
output_min: f32,
output_max: f32,
integral_max: f32,
// Derivative filter coefficient (0.0 = no filter, 0.9 = heavy filter)
alpha: f32,
// Cached inputs
target: f32,
measured: f32,
}
impl PidNode {
fn new(kp: f32, ki: f32, kd: f32) -> Result<Self> {
Ok(Self {
setpoint_sub: Topic::new("pid.setpoint")?,
measurement_sub: Topic::new("pid.measurement")?,
output_pub: Topic::new("pid.output")?,
kp, ki, kd,
integral: 0.0,
prev_error: 0.0,
prev_derivative: 0.0,
output_min: -1.0,
output_max: 1.0,
integral_max: 0.5, // anti-windup limit
alpha: 0.8, // derivative low-pass filter
target: 0.0,
measured: 0.0,
})
}
}
impl Node for PidNode {
fn name(&self) -> &str { "PID" }
fn tick(&mut self) {
// IMPORTANT: always recv() every tick to drain buffers
if let Some(sp) = self.setpoint_sub.recv() {
self.target = sp.target;
}
if let Some(m) = self.measurement_sub.recv() {
self.measured = m.value;
}
let dt = 1.0 / 200.0; // 200Hz control rate
let error = self.target - self.measured;
// P term
let p_term = self.kp * error;
// I term with anti-windup clamping
self.integral += error * dt;
self.integral = self.integral.clamp(-self.integral_max, self.integral_max);
let i_term = self.ki * self.integral;
// D term with low-pass filter to reduce noise
let raw_derivative = (error - self.prev_error) / dt;
let filtered = self.alpha * self.prev_derivative + (1.0 - self.alpha) * raw_derivative;
let d_term = self.kd * filtered;
self.prev_derivative = filtered;
self.prev_error = error;
// Total output with saturation
let command = (p_term + i_term + d_term).clamp(self.output_min, self.output_max);
self.output_pub.send(ControlOutput {
command,
error,
p_term,
i_term,
d_term,
});
}
fn shutdown(&mut self) -> Result<()> {
// SAFETY: zero the control output on shutdown
self.output_pub.send(ControlOutput::default());
Ok(())
}
}
fn main() -> Result<()> {
let mut scheduler = Scheduler::new();
// PID gains: tune for your plant
// Execution order: PID reads setpoint + measurement, publishes output
scheduler.add(PidNode::new(2.0, 0.5, 0.1)?)
.order(0)
.rate(200_u64.hz()) // 200Hz control loop — auto-enables RT
.budget(400.us()) // 400μs budget (tight for control)
.on_miss(Miss::Warn)
.build()?;
scheduler.run()
}
Expected Output
[HORUS] Scheduler running — tick_rate: 200 Hz
[HORUS] Node "PID" started (Rt, 200 Hz, budget: 400μs, deadline: 4.75ms)
^C
[HORUS] Shutting down...
[HORUS] Node "PID" shutdown complete
Key Points
- Anti-windup: Integral term is clamped to
integral_max— prevents windup during saturation - Derivative filter: Low-pass filter (alpha=0.8) smooths noisy sensor feedback
ControlOutputincludes debug fields (error,p_term,i_term,d_term) for tuningshutdown()zeros output — prevents actuator from holding last command- 200Hz is typical for position/velocity PID; use 1kHz+ for current/torque loops
- Gains (kp, ki, kd) are constructor parameters — wire from config or topic for online tuning