Scanbot Pid Controller

Task Details
← Task Board

Task Description

# 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)
```

Job Queue (0)

No job queue entries for this task yet