sprint-3: PID outer + Heading Hold + ROT feed-forward + gain scheduling

End-to-end implementation per docs/sprint-3-plan.md.

Closes the cascade: outer loop (heading control, 10 Hz on Core 1) drives
the inner loop (rudder position control, 50 Hz from Sprint 2). First real
mode other than STANDBY is now activable: HEADING_HOLD.

Builds: pio run -e esp32-dev SUCCESS, RAM 6.8%, Flash 27.1% (355 KB).
Tests: pytest 258/258 green (231 Sprint 2.5 + 27 Sprint 3 new).

Python (arautopilot/studio/simulator/):

- vessel_heading.py: first-order yaw model. ROT responds to
  rudder*speed; damping returns ROT to zero when rudder is centred.
  Defaults tuned so 5 deg rudder @ 10 kn -> ~3 dps steady-state ROT.
  Includes heading_error_deg() shortest-arc helper.
- pid_outer.py: pure-Python outer heading PID. Anti-windup via back-
  calculation, gain scheduling by SOG, deadband, derivative LPF,
  output saturation, ROT feed-forward (brief sec. 6 -- the term that
  distinguishes a premium autopilot from a basic one), rate limit on
  produced rudder setpoint, shortest-arc heading wrap-around.

Firmware (firmware/ar_autopilot_v1/src/pid/):

- pid_outer.h: header-only C++17 port. Same algorithm, same variables,
  same numerics. Fixed-capacity gain schedule (up to 8 points).
- pid_outer_task.{h,cpp}: 10 Hz FreeRTOS task on Core 1. Subscribes to
  TWDT. Reads heading + ROT from the NMEA 2000 snapshot. Uses
  operator-configurable SOG (default 15 kn until PGN 129026 wiring in
  Sprint 5). Pushes rudder setpoint into the inner loop only when
  current_mode == HEADING_HOLD.

Modes (firmware/ar_autopilot_v1/src/modes/standby.cpp):

- HEADING_HOLD activable via request_mode(). Pre-conditions:
    * NMEA 2000 heading sensor valid (fresh PGN 127250)
    * Rudder sensor valid (median filter filled)
  On success, captures current heading as initial setpoint so the
  operator doesn't get a sudden swing toward an old setpoint.

Modbus (regenerated from YAML):

- 7 new INPUTs (50-56): outer heading setpoint, produced rudder
  setpoint, error, current SOG, live kp/ki/kd.
- 5 new HOLDINGs (24-28): writable heading setpoint, SOG override,
  outer base gains. Writing any of kp/ki/kd disables the built-in
  3-point gain schedule (operator override).

Tests:

- test_vessel_heading_simulator.py: 6 dynamics tests + 9 parameterised
  heading_error_deg edge cases (wrap-around).
- test_pid_outer_python.py: 12 tests covering gain interpolation,
  per-tick PID behaviour (deadband, sign, ROT feed-forward,
  saturation, rate limit, allowed=false), and three end-to-end cascade
  tests (positive step, negative step, wrap-around 360->10).

Cascade verification: outer + inner + rudder dynamics + vessel-heading
simulator settles a 30 deg step within +-2 deg in 60 s.

NOT in Sprint 3 (intentional):
  - True Course / Track Keeping / Dodge -- Sprint 5
  - Off-course alarms + auto-disengage on sensor loss -- Sprint 6
  - COG / SOG / Position via N2K PGN 129025/9/6 -- Sprint 5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:20:23 -04:00
parent 13a2867ef6
commit 42ee63b776
15 changed files with 1462 additions and 9 deletions
+34 -5
View File
@@ -6,6 +6,9 @@
#include <Arduino.h>
#include "../hal/rudder_sensor.h"
#include "../pid/pid_outer_task.h"
#include "../protocols/nmea2000_consumer.h"
#include "../system/ar_log.h"
namespace arautopilot::modes {
@@ -45,9 +48,6 @@ Mode current_mode() {
bool is_standby() { return current_mode() == Mode::STANDBY; }
bool request_mode(Mode m) {
// Sprint 1: only STANDBY is reachable. The other modes exist in the enum
// for forward compatibility (Modbus already has slots for them) but the
// PID and modes machinery is not in place yet.
if (m == Mode::STANDBY) {
portENTER_CRITICAL(&g_mux);
Mode prev = g_mode;
@@ -59,9 +59,38 @@ bool request_mode(Mode m) {
return true;
}
// ----- HEADING_HOLD activation (Sprint 3) -----
if (m == Mode::HEADING_HOLD) {
const auto n2k = protocols::nmea2000::nmea2000_latest();
const auto rd = hal::rudder_sensor_latest();
if (!n2k.heading_valid) {
AR_LOGW(TAG, "request_mode(HEADING_HOLD) rejected: heading sensor "
"not valid (no fresh PGN 127250)");
return false;
}
if (!rd.valid) {
AR_LOGW(TAG, "request_mode(HEADING_HOLD) rejected: rudder sensor "
"not valid yet (median filter still filling)");
return false;
}
// Capture the current heading as the initial setpoint so the
// operator doesn't get a sudden swing toward whatever was in
// the holding register beforehand.
pid::pid_outer_set_heading_setpoint_deg(n2k.heading_deg);
portENTER_CRITICAL(&g_mux);
Mode prev = g_mode;
g_mode = Mode::HEADING_HOLD;
portEXIT_CRITICAL(&g_mux);
AR_LOGI(TAG, "mode change: %s -> HEADING_HOLD (setpoint captured at %.1f deg)",
mode_name(prev), n2k.heading_deg);
return true;
}
// TRUE_COURSE / TRACK_KEEPING / DODGE: Sprint 5.
AR_LOGW(TAG,
"request_mode(%s) rejected: only STANDBY is implemented in "
"Sprint 1; current mode remains %s",
"request_mode(%s) rejected: mode not yet implemented "
"(Sprint 5+); current mode remains %s",
mode_name(m), mode_name(current_mode()));
return false;
}