# Task: Build Scanbot PID Controller
**Priority:** HIGH
**Target file:** `Scanbot/src/control/pid_controller.py`
**Dependencies:** None (standalone module, only uses math and dataclasses)
## What to Build
A generic PID controller class with anti-windup, deadband, derivative low-pass filter, and static tuning methods. This will be used by both steering and speed control systems.
## Class Specification
```python
class PIDController:
def __init__(self, kp=1.0, ki=0.0, kd=0.0, output_min=-1.0, output_max=1.0,
integral_max=None, deadband=0.0, d_filter_coeff=0.1)
def compute(self, setpoint: float, measurement: float, dt: float) -> float
# 1. error = setpoint - measurement
# 2. Apply deadband: if abs(error) < deadband, error = 0
# 3. P = kp * error
# 4. I = ki * integral (clamp integral to integral_max for anti-windup)
# 5. D = kd * derivative with low-pass filter:
# d_raw = (error - prev_error) / dt
# d_filtered = d_filter_coeff * d_raw + (1 - d_filter_coeff) * prev_d_filtered
# 6. output = clamp(P + I + D, output_min, output_max)
# 7. Anti-windup: if output is saturated, don't accumulate integral further
def reset(self)
# Zero out integral, derivative, prev_error
def set_gains(self, kp=None, ki=None, kd=None)
# Update gains without resetting state
def get_state(self) -> dict
# Returns {kp, ki, kd, p_term, i_term, d_term, error, output, integral}
@staticmethod
def tune_from_step_response(rise_time_s: float, overshoot_pct: float,
settling_time_s: float, step_magnitude: float,
steady_state_value: float) -> tuple[float, float, float]:
# Use AMIGO tuning rules:
# K_process = steady_state_value / step_magnitude
# tau = rise_time_s (approximate time constant)
# L = delay (approximate as rise_time * 0.1)
# Kp = (0.15 / K_process) * (tau / L + 0.35)
# Ti = (0.35 * L * (tau + 0.5 * L)) / (tau + 0.1 * L)
# Td = (0.5 * L * tau) / (tau + 0.1 * L)
# Ki = Kp / Ti, Kd = Kp * Td
# Returns (kp, ki, kd)
@staticmethod
def tune_ziegler_nichols(ku: float, tu: float,
controller_type: str = "pid") -> tuple[float, float, float]:
# Classic Ziegler-Nichols from ultimate gain (ku) and period (tu)
# P: Kp = 0.5 * Ku
# PI: Kp = 0.45 * Ku, Ki = Kp / (Tu / 1.2)
# PID: Kp = 0.6 * Ku, Ki = Kp / (Tu / 2), Kd = Kp * Tu / 8
# Returns (kp, ki, kd)
```
## Key Requirements
1. **Anti-windup:** When output hits min/max limits, stop accumulating integral
2. **Derivative filter:** Low-pass filter on derivative term to reduce noise (default alpha=0.1)
3. **Deadband:** Ignore errors smaller than threshold (prevents hunting near setpoint)
4. **Thread-safe:** Will be called from 20Hz control loop
5. **No external dependencies** — only `math`, `time`, `dataclasses`
6. **Include docstrings** for all public methods
## Testing
The module should work standalone:
```python
pid = PIDController(kp=1.0, ki=0.1, kd=0.05, output_min=-100, output_max=100)
for _ in range(100):
output = pid.compute(setpoint=10.0, measurement=current_value, dt=0.05)
```