# Sprint 2 — PID inner loop + Python rudder simulator

> Brief reference: §12 Sprint 2.
> Status: executed overnight under user's blanket authorisation.

## Objetivo

Lazo cerrado de **posición de timón**: dado un setpoint en grados, comandar la
bomba/motor para llegar y mantener esa posición. Frecuencia 50 Hz (hard
real-time, brief §6).

Sin esto el Sprint 3 (heading control) no tiene sobre qué construir.

## Estrategia

Dado que no tengo hardware AR-NMEA-IO ni compilador C++ host, sigo este
camino:

1. **Simulador de timón en Python** (`arautopilot.studio.simulator.rudder_dynamics`):
   modelo físico mínimo de un timón con inercia rotacional, fricción viscosa,
   deadband hidráulico y asimetría babor/estribor. Frecuencia de integración
   libre (1 ms).
2. **PID inner loop en Python** (`arautopilot.studio.simulator.pid_inner`):
   implementación pura-Python del algoritmo, anti-windup tipo
   back-calculation, deadband del setpoint, rate limit, saturación, y
   compensación de no-linealidades del actuador (offset por deadband,
   ganancia asimétrica). Usado para iterar rápido contra el simulador y
   producir las curvas de respuesta esperadas.
3. **PID inner loop en C++** (`firmware/.../src/pid/pid_inner.{h,cpp}`):
   port línea-por-línea del algoritmo Python. Compila tanto en ESP32 como
   en host-native (sin dependencias de Arduino). Mismas constantes,
   mismos coeficientes, misma topología.
4. **Cross-validation tests**: pytest carga el módulo Python directamente
   y un wrapper ctypes del PID C++ compilado, los corre contra el mismo
   simulador y verifica diferencia < 1e-3 en la trayectoria.
5. **Tests Unity host-side** del PID C++ (corren cuando hay g++/clang).
6. **Wire al firmware**: el `pid_inner_task` ya estaba creado como stub
   en task_config.h; ahora hace algo útil. Lee setpoint de Modbus
   (HOLDING_HEADING_SETPOINT_X100 reasignado pro-tem para
   "rudder setpoint", se separa correctamente en Sprint 3), lee posición
   de `hal::rudder_sensor_latest()`, comanda
   `hal::rudder_command(pwm_pct)`.
7. **Modbus expone**: ganancias activas (read), setpoint actual (RW),
   error instantáneo (read), salida PID en % (read).

## Salvaguardas implementadas (todas obligatorias por brief §6)

- **Anti-windup**: integrador clamped a `[-anti_windup_limit, +anti_windup_limit]`,
  además back-calculation cuando la salida satura
- **Deadband del setpoint**: ±0.5° por defecto (configurable) — evita
  oscilación por ruido
- **Saturación de salida**: comando en %, limitado a [-100, +100]
- **Rate limit del setpoint interno**: 3-6 °/s típico (configurable)
- **Compensación de deadband del actuador**: si `|cmd| > 0`, snap to
  `min_useful_pwm_pct`
- **Asimetría babor/estribor**: ganancia `asymmetry_stbd_over_port` aplicada
  al lado correspondiente
- **Refuse-to-act** en STANDBY (ya estaba desde Sprint 1)

## Validación

- `pytest` verde con tests Python contra el simulador (step response,
  disturbance rejection, deadband behaviour, saturation, anti-windup,
  asymmetry).
- `pio run -e esp32-dev` compila clean.
- Cross-validation Python ↔ C++: trayectorias coinciden < 1e-3.

## Lo que NO hace Sprint 2

- No es lazo de rumbo. Eso es Sprint 3 (PID outer).
- No tiene gain scheduling por velocidad. Sprint 3.
- No tiene ROT feed-forward. Sprint 3.
- No usa modos distintos a STANDBY (sigue rechazando otros). Modes
  reales en Sprint 3 cuando el outer loop entra.
