feat: Steering PID controller (Issue #616) #624

Merged
sl-jetson merged 1 commits from sl-controls/issue-616-steering-pid into main 2026-03-15 11:02:33 -04:00
7 changed files with 953 additions and 0 deletions
Showing only changes of commit 1e69337ffd - Show all commits

View File

@ -50,6 +50,8 @@
* 0x87 FAULT_LOG - jlink_tlm_fault_log_t (20 bytes), sent on boot + FAULT_LOG_GET (Issue #565)
* 0x88 SLOPE - jlink_tlm_slope_t (4 bytes), sent at SLOPE_TLM_HZ (Issue #600)
* 0x89 CAN_STATS - jlink_tlm_can_stats_t (16 bytes), sent at CAN_TLM_HZ + CAN_STATS_GET (Issue #597)
* 0x8A STEERING - jlink_tlm_steering_t (8 bytes), sent at STEER_TLM_HZ (Issue #616)
* 0x8B LVC - jlink_tlm_lvc_t (4 bytes), sent at LVC_TLM_HZ (Issue #613)
*
* Priority: CRSF RC always takes precedence. Jetson steer/speed only applied
* when mode_manager_active() == MODE_AUTONOMOUS (CH6 high). In RC_MANUAL and
@ -93,6 +95,8 @@
#define JLINK_TLM_FAULT_LOG 0x87u /* jlink_tlm_fault_log_t (20 bytes, Issue #565) */
#define JLINK_TLM_SLOPE 0x88u /* jlink_tlm_slope_t (4 bytes, Issue #600) */
#define JLINK_TLM_CAN_STATS 0x89u /* jlink_tlm_can_stats_t (16 bytes, Issue #597) */
#define JLINK_TLM_STEERING 0x8Au /* jlink_tlm_steering_t (8 bytes, Issue #616) */
#define JLINK_TLM_LVC 0x8Bu /* jlink_tlm_lvc_t (4 bytes, Issue #613) */
/* ---- Telemetry STATUS payload (20 bytes, packed) ---- */
typedef struct __attribute__((packed)) {
@ -204,6 +208,24 @@ typedef struct __attribute__((packed)) {
int16_t vel1_rpm; /* node 1 current velocity (RPM) */
} jlink_tlm_can_stats_t; /* 16 bytes */
/* ---- Telemetry STEERING payload (8 bytes, packed) Issue #616 ---- */
/* Published at STEER_TLM_HZ (10 Hz); reports yaw-rate PID state. */
typedef struct __attribute__((packed)) {
int16_t target_x10; /* target yaw rate, deg/s × 10 (0.1 deg/s resolution) */
int16_t actual_x10; /* measured yaw rate, deg/s × 10 */
int16_t output; /* differential motor output (-STEER_OUTPUT_MAX..+MAX) */
uint8_t enabled; /* 1 = PID active */
uint8_t _pad; /* reserved */
} jlink_tlm_steering_t; /* 8 bytes */
/* ---- Telemetry LVC payload (4 bytes, packed) Issue #613 ---- */
/* Sent at LVC_TLM_HZ (1 Hz); reports battery voltage and LVC protection state. */
typedef struct __attribute__((packed)) {
uint16_t voltage_mv; /* battery voltage (mV) */
uint8_t percent; /* 0-100: fuel gauge within CUTOFF..WARNING; 255=unknown */
uint8_t protection_state; /* LvcState: 0=NORMAL,1=WARNING,2=CRITICAL,3=CUTOFF */
} jlink_tlm_lvc_t; /* 4 bytes */
/* ---- Volatile state (read from main loop) ---- */
typedef struct {
/* Drive command - updated on JLINK_CMD_DRIVE */
@ -322,4 +344,17 @@ void jlink_send_slope_tlm(const jlink_tlm_slope_t *tlm);
*/
void jlink_send_can_stats(const jlink_tlm_can_stats_t *tlm);
/*
* jlink_send_steering_tlm(tlm) - transmit JLINK_TLM_STEERING (0x8A) frame
* (14 bytes total) to Jetson. Issue #616.
* Rate-limiting is handled by steering_pid_send_tlm(); call from there only.
*/
void jlink_send_steering_tlm(const jlink_tlm_steering_t *tlm);
/*
* jlink_send_lvc_tlm(tlm) - transmit JLINK_TLM_LVC (0x8B) frame
* (10 bytes total) at LVC_TLM_HZ (1 Hz). Issue #613.
*/
void jlink_send_lvc_tlm(const jlink_tlm_lvc_t *tlm);
#endif /* JLINK_H */

View File

@ -9,6 +9,7 @@ typedef struct {
float pitch_rate; // degrees/sec (raw gyro pitch axis)
float roll; // degrees, filtered (complementary filter)
float yaw; // degrees, gyro-integrated (drifts — no magnetometer)
float yaw_rate; // degrees/sec (raw gyro Z / board_gz, Issue #616)
float accel_x; // g
float accel_z; // g
} IMUData;

134
include/steering_pid.h Normal file
View File

@ -0,0 +1,134 @@
#ifndef STEERING_PID_H
#define STEERING_PID_H
#include <stdint.h>
#include <stdbool.h>
/*
* steering_pid closed-loop yaw-rate controller for differential drive
* (Issue #616).
*
* OVERVIEW:
* Converts a desired yaw rate (from Jetson Twist.angular.z) into a
* differential wheel speed offset. The balance PID remains the primary
* controller; the steering PID generates a small differential term that
* is added to the balance command inside motor_driver:
*
* left_speed = balance_cmd - steer_out
* right_speed = balance_cmd + steer_out
*
* This is the standard differential-drive mixing already performed by
* the ESC backend (hoverboard/VESC).
*
* INPUT SIGNALS:
* target_omega_dps : desired yaw rate in deg/s (+ = clockwise from above)
* Derived from JLINK_CMD_DRIVE steer field:
* target_omega_dps = steer * STEER_OMEGA_SCALE
* (steer is int16 -1000..+1000 from Jetson)
* actual_omega_dps : measured yaw rate from IMU gyro Z (deg/s)
* = IMUData.yaw_rate
*
* PID ALGORITHM:
* error = target_omega - actual_omega
* integral = clamp(integral + error*dt, ±STEER_INTEGRAL_MAX)
* raw_out = Kp*error + Ki*integral + Kd*(error - prev_error)/dt
* raw_out = clamp(raw_out, ±STEER_OUTPUT_MAX)
* output = rate_limit(raw_out, STEER_RAMP_RATE_PER_MS * dt_ms)
*
* ANTI-WINDUP:
* Integral is clamped to ±STEER_INTEGRAL_MAX counts before the Ki
* multiply, bounding the integrator contribution independently of Kp/Kd.
*
* RATE LIMITER:
* Output changes at most STEER_RAMP_RATE_PER_MS counts per millisecond.
* Prevents a sudden step in steering demand from disturbing the balance
* PID (which has no knowledge of the steering channel).
*
* OMEGA SCALING:
* STEER_OMEGA_SCALE = 0.1 deg/s per steer unit.
* Range: steer -1000..+1000 omega -100..+100 deg/s.
* 100 deg/s 1.75 rad/s covers aggressive turns without exceeding
* the hoverboard ESC differential authority (STEER_OUTPUT_MAX = 400).
*
* DISABLING:
* steering_pid_set_enabled(s, false) zeroes target_omega and integral,
* then freezes output at 0. Use when Jetson is not active or in
* RC_MANUAL mode to avoid fighting the RC steer channel.
*
* TELEMETRY:
* JLINK_TLM_STEERING (0x8A) published at STEER_TLM_HZ (10 Hz):
* jlink_tlm_steering_t { int16 target_x10, int16 actual_x10,
* int16 output, uint8 enabled, uint8 _pad }
* 8 bytes total.
*/
/* ---- Configuration ---- */
#define STEER_KP 2.0f /* proportional gain (counts / (deg/s)) */
#define STEER_KI 0.5f /* integral gain (counts / (deg)) */
#define STEER_KD 0.05f /* derivative gain (counts / (deg/s²)) */
#define STEER_INTEGRAL_MAX 200.0f /* integrator clamp (motor counts) */
#define STEER_OUTPUT_MAX 400 /* peak differential output (counts) */
#define STEER_RAMP_RATE_PER_MS 10 /* max output change per ms (counts/ms) */
#define STEER_OMEGA_SCALE 0.1f /* steer units → deg/s (0.1 deg/s/unit) */
#define STEER_TLM_HZ 10u /* JLINK_TLM_STEERING publish rate (Hz) */
/* ---- State ---- */
typedef struct {
float target_omega_dps; /* setpoint: desired yaw rate (deg/s) */
float actual_omega_dps; /* feedback: measured yaw rate (deg/s) */
float integral; /* PID integrator (motor counts·s) */
float prev_error; /* error at last update (deg/s) */
int16_t output; /* rate-limited differential output */
bool enabled; /* false = output held at 0 */
uint32_t last_tlm_ms; /* rate-limit for TLM */
} steering_pid_t;
/* ---- API ---- */
/*
* steering_pid_init(s) zero state, enable controller.
* Call once during system init.
*/
void steering_pid_init(steering_pid_t *s);
/*
* steering_pid_reset(s) zero integrator, setpoint and output.
* Preserves enabled flag. Call on disarm.
*/
void steering_pid_reset(steering_pid_t *s);
/*
* steering_pid_set_target(s, omega_dps) update setpoint.
* omega_dps : desired yaw rate in deg/s.
* Converts from JLINK_CMD_DRIVE steer field: omega = steer * STEER_OMEGA_SCALE.
* No-op if disabled (output remains 0).
*/
void steering_pid_set_target(steering_pid_t *s, float omega_dps);
/*
* steering_pid_update(s, actual_omega_dps, dt) advance PID one step.
* actual_omega_dps : IMU gyro Z rate (deg/s) use IMUData.yaw_rate.
* dt : loop interval (seconds).
* Returns differential output (-STEER_OUTPUT_MAX..+STEER_OUTPUT_MAX).
* Returns 0 if disabled or dt <= 0.
*/
int16_t steering_pid_update(steering_pid_t *s, float actual_omega_dps, float dt);
/*
* steering_pid_get_output(s) last computed differential output.
*/
int16_t steering_pid_get_output(const steering_pid_t *s);
/*
* steering_pid_set_enabled(s, en) enable or disable the controller.
* Disabling resets integrator and zeroes output.
*/
void steering_pid_set_enabled(steering_pid_t *s, bool en);
/*
* steering_pid_send_tlm(s, now_ms) transmit JLINK_TLM_STEERING (0x8A)
* frame to Jetson. Rate-limited to STEER_TLM_HZ; safe to call every tick.
*/
void steering_pid_send_tlm(const steering_pid_t *s, uint32_t now_ms);
#endif /* STEERING_PID_H */

View File

@ -618,3 +618,27 @@ void jlink_send_can_stats(const jlink_tlm_can_stats_t *tlm)
jlink_tx_locked(frame, sizeof(frame));
}
/* ---- jlink_send_lvc_tlm() -- Issue #613 ---- */
void jlink_send_lvc_tlm(const jlink_tlm_lvc_t *tlm)
{
/*
* Frame: [STX][LEN][0x8B][4 bytes LVC][CRC_hi][CRC_lo][ETX]
* LEN = 1 + 4 = 5; total = 10 bytes
*/
static uint8_t frame[10];
const uint8_t plen = (uint8_t)sizeof(jlink_tlm_lvc_t); /* 4 */
const uint8_t len = 1u + plen; /* 5 */
frame[0] = JLINK_STX;
frame[1] = len;
frame[2] = JLINK_TLM_LVC;
memcpy(&frame[3], tlm, plen);
uint16_t crc = crc16_xmodem(&frame[2], len);
frame[3 + plen] = (uint8_t)(crc >> 8);
frame[3 + plen + 1] = (uint8_t)(crc & 0xFFu);
frame[3 + plen + 2] = JLINK_ETX;
jlink_tx_locked(frame, sizeof(frame));
}

View File

@ -151,6 +151,7 @@ void mpu6000_read(IMUData *data) {
data->pitch_rate = gyro_pitch_rate;
data->roll = s_roll;
data->yaw = s_yaw;
data->yaw_rate = gyro_yaw_rate; /* board_gz: raw bias-corrected gyro Z (Issue #616) */
data->accel_x = ax;
data->accel_z = az;
}

140
src/steering_pid.c Normal file
View File

@ -0,0 +1,140 @@
/*
* steering_pid.c closed-loop yaw-rate PID for differential drive (Issue #616).
*
* Converts Jetson Twist.angular.z (encoded as steer * STEER_OMEGA_SCALE deg/s)
* into a differential wheel speed offset using IMU gyro Z as feedback.
*
* Anti-windup: integral clamped to ±STEER_INTEGRAL_MAX before Ki multiply.
* Rate limiter: output changes at most STEER_RAMP_RATE_PER_MS per ms so that
* a step in steering demand cannot disturb the balance PID.
*/
#include "steering_pid.h"
#include "jlink.h"
/* ---- steering_pid_init() ---- */
void steering_pid_init(steering_pid_t *s)
{
s->target_omega_dps = 0.0f;
s->actual_omega_dps = 0.0f;
s->integral = 0.0f;
s->prev_error = 0.0f;
s->output = 0;
s->enabled = true;
/* Initialize so first send_tlm call fires immediately */
s->last_tlm_ms = (uint32_t)(-(uint32_t)(1000u / (STEER_TLM_HZ > 0u ? STEER_TLM_HZ : 1u)));
}
/* ---- steering_pid_reset() ---- */
void steering_pid_reset(steering_pid_t *s)
{
s->target_omega_dps = 0.0f;
s->integral = 0.0f;
s->prev_error = 0.0f;
s->output = 0;
}
/* ---- steering_pid_set_target() ---- */
void steering_pid_set_target(steering_pid_t *s, float omega_dps)
{
if (!s->enabled) return;
s->target_omega_dps = omega_dps;
}
/* ---- steering_pid_update() ---- */
int16_t steering_pid_update(steering_pid_t *s, float actual_omega_dps, float dt)
{
if (!s->enabled || dt <= 0.0f) {
s->output = 0;
return 0;
}
s->actual_omega_dps = actual_omega_dps;
/* PID error */
float error = s->target_omega_dps - actual_omega_dps;
/* Proportional */
float p_term = STEER_KP * error;
/* Integral with anti-windup clamp */
s->integral += error * dt;
if (s->integral > STEER_INTEGRAL_MAX) s->integral = STEER_INTEGRAL_MAX;
if (s->integral < -STEER_INTEGRAL_MAX) s->integral = -STEER_INTEGRAL_MAX;
float i_term = STEER_KI * s->integral;
/* Derivative on error (avoids setpoint kick for smooth yaw changes) */
float d_term = 0.0f;
if (dt > 0.0f) {
d_term = STEER_KD * (error - s->prev_error) / dt;
}
s->prev_error = error;
/* Sum and clamp raw output */
float raw = p_term + i_term + d_term;
if (raw > (float)STEER_OUTPUT_MAX) raw = (float)STEER_OUTPUT_MAX;
if (raw < -(float)STEER_OUTPUT_MAX) raw = -(float)STEER_OUTPUT_MAX;
/* Rate limiter: bound change per step by STEER_RAMP_RATE_PER_MS * dt */
float max_step = (float)STEER_RAMP_RATE_PER_MS * (dt * 1000.0f);
float delta = raw - (float)s->output;
if (delta > max_step) delta = max_step;
if (delta < -max_step) delta = -max_step;
float limited = (float)s->output + delta;
/* Final clamp after rate limit */
if (limited > (float)STEER_OUTPUT_MAX) limited = (float)STEER_OUTPUT_MAX;
if (limited < -(float)STEER_OUTPUT_MAX) limited = -(float)STEER_OUTPUT_MAX;
s->output = (int16_t)limited;
return s->output;
}
/* ---- steering_pid_get_output() ---- */
int16_t steering_pid_get_output(const steering_pid_t *s)
{
return s->output;
}
/* ---- steering_pid_set_enabled() ---- */
void steering_pid_set_enabled(steering_pid_t *s, bool en)
{
if (!en && s->enabled) {
/* Disabling: zero out state */
s->target_omega_dps = 0.0f;
s->integral = 0.0f;
s->prev_error = 0.0f;
s->output = 0;
}
s->enabled = en;
}
/* ---- steering_pid_send_tlm() ---- */
void steering_pid_send_tlm(const steering_pid_t *s, uint32_t now_ms)
{
if (STEER_TLM_HZ == 0u) return;
uint32_t interval_ms = 1000u / STEER_TLM_HZ;
if ((now_ms - s->last_tlm_ms) < interval_ms) return;
/* Cast away const for timestamp update — only mutable field */
((steering_pid_t *)s)->last_tlm_ms = now_ms;
jlink_tlm_steering_t tlm;
/* Scale to ×10 for 0.1 deg/s resolution, clamped to int16 range */
float t = s->target_omega_dps * 10.0f;
if (t > 32767.0f) t = 32767.0f;
if (t < -32768.0f) t = -32768.0f;
tlm.target_x10 = (int16_t)t;
float a = s->actual_omega_dps * 10.0f;
if (a > 32767.0f) a = 32767.0f;
if (a < -32768.0f) a = -32768.0f;
tlm.actual_x10 = (int16_t)a;
tlm.output = s->output;
tlm.enabled = s->enabled ? 1u : 0u;
tlm._pad = 0u;
jlink_send_steering_tlm(&tlm);
}

618
test/test_steering_pid.c Normal file
View File

@ -0,0 +1,618 @@
/*
* test_steering_pid.c unit tests for steering_pid (Issue #616).
*
* Host build (no HAL):
* gcc -I include -I src -DTEST_HOST -lm -o /tmp/test_steering test/test_steering_pid.c
* /tmp/test_steering
*
* Stubs out jlink.h to avoid the full HAL dependency chain.
*/
/* ---- Minimal stubs ---- */
#define JLINK_H /* prevent real jlink.h from loading */
#include <stdint.h>
#include <stdbool.h>
/* Minimal jlink_tlm_steering_t and send stub */
typedef struct __attribute__((packed)) {
int16_t target_x10;
int16_t actual_x10;
int16_t output;
uint8_t enabled;
uint8_t _pad;
} jlink_tlm_steering_t;
static jlink_tlm_steering_t g_last_tlm;
static int g_tlm_count = 0;
void jlink_send_steering_tlm(const jlink_tlm_steering_t *tlm)
{
g_last_tlm = *tlm;
g_tlm_count++;
}
/* ---- Include the module under test ---- */
#include "steering_pid.h"
#include "steering_pid.c" /* single-TU build; steering_pid.c found via -I src */
/* ---- Test framework ---- */
#include <stdio.h>
#include <math.h>
#include <string.h>
static int s_pass = 0;
static int s_fail = 0;
#define EXPECT(cond, msg) do { \
if (cond) { s_pass++; } \
else { s_fail++; printf("FAIL [%s:%d]: %s\n", __func__, __LINE__, msg); } \
} while(0)
#define EXPECT_EQ(a, b, msg) EXPECT((a) == (b), msg)
#define EXPECT_NEAR(a, b, eps, msg) EXPECT(fabsf((a) - (b)) <= (eps), msg)
#define EXPECT_TRUE(x, msg) EXPECT((x), msg)
#define EXPECT_FALSE(x, msg) EXPECT(!(x), msg)
/* ---- Tests ---- */
static void test_init(void)
{
steering_pid_t s;
steering_pid_init(&s);
EXPECT_NEAR(s.target_omega_dps, 0.0f, 1e-6f, "init: target_omega 0");
EXPECT_NEAR(s.actual_omega_dps, 0.0f, 1e-6f, "init: actual_omega 0");
EXPECT_NEAR(s.integral, 0.0f, 1e-6f, "init: integral 0");
EXPECT_NEAR(s.prev_error, 0.0f, 1e-6f, "init: prev_error 0");
EXPECT_EQ (s.output, 0, "init: output 0");
EXPECT_TRUE(s.enabled, "init: enabled true");
}
static void test_reset(void)
{
steering_pid_t s;
steering_pid_init(&s);
s.target_omega_dps = 30.0f;
s.integral = 100.0f;
s.prev_error = 5.0f;
s.output = 200;
steering_pid_reset(&s);
EXPECT_NEAR(s.target_omega_dps, 0.0f, 1e-6f, "reset: target 0");
EXPECT_NEAR(s.integral, 0.0f, 1e-6f, "reset: integral 0");
EXPECT_NEAR(s.prev_error, 0.0f, 1e-6f, "reset: prev_error 0");
EXPECT_EQ (s.output, 0, "reset: output 0");
EXPECT_TRUE(s.enabled, "reset: preserves enabled");
}
static void test_disabled_returns_zero(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_enabled(&s, false);
steering_pid_set_target(&s, 50.0f);
int16_t out = steering_pid_update(&s, 0.0f, 0.001f);
EXPECT_EQ(out, 0, "disabled: update returns 0");
EXPECT_EQ(steering_pid_get_output(&s), 0, "disabled: get_output 0");
/* Target should not have been set when disabled */
EXPECT_NEAR(s.target_omega_dps, 0.0f, 1e-6f, "disabled: target not set");
}
static void test_zero_dt_returns_zero(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 30.0f);
int16_t out = steering_pid_update(&s, 0.0f, 0.0f);
EXPECT_EQ(out, 0, "zero_dt: returns 0");
}
static void test_negative_dt_returns_zero(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 30.0f);
int16_t out = steering_pid_update(&s, 0.0f, -0.01f);
EXPECT_EQ(out, 0, "neg_dt: returns 0");
}
static void test_proportional_only(void)
{
/*
* With Ki=0 and Kd=0, output = Kp * error.
* Set STEER_KP=2.0, error=50 deg/s raw = 100 counts.
* Rate limiter allows max_step = STEER_RAMP_RATE_PER_MS * dt_ms = 10 * 1 = 10 per step.
* After 1 step (dt=0.001s): output capped at 10 by rate limiter.
*/
steering_pid_t s;
memset(&s, 0, sizeof(s));
steering_pid_init(&s);
s.integral = 0.0f;
s.prev_error = 0.0f;
s.output = 0;
steering_pid_set_target(&s, 50.0f);
int16_t out = steering_pid_update(&s, 0.0f, 0.001f);
/* Rate limiter: max_step = 10*0.001*1000 = 10 → output = 10 */
EXPECT_EQ(out, 10, "prop_only: rate-limited step");
}
static void test_converges_to_setpoint(void)
{
/*
* With no disturbance, run many steps until output reaches the setpoint.
* With target=20 deg/s, Kp=2, after many steps the P term = 2*20 = 40,
* plus I accumulation. The rate limiter will eventually allow the output
* to grow. Run for 1s at 1kHz (1000 steps, dt=0.001s each).
* Expected: eventually output > 0 (PID working), integral accumulates.
*/
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 20.0f);
int16_t out = 0;
for (int i = 0; i < 1000; i++) {
out = steering_pid_update(&s, 0.0f, 0.001f);
}
EXPECT_TRUE(out > 0, "convergence: output positive after 1s");
EXPECT_TRUE(s.integral > 0.0f, "convergence: integral positive");
}
static void test_output_clamped_to_max(void)
{
/*
* Very large error should produce output clamped to STEER_OUTPUT_MAX.
* Run many ticks so rate limiter is satisfied.
* STEER_OUTPUT_MAX = 400; 400 ticks at 10 counts/ms should saturate.
*/
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 500.0f); /* 500 deg/s — way beyond robot capability */
int16_t out = 0;
for (int i = 0; i < 500; i++) {
out = steering_pid_update(&s, 0.0f, 0.001f);
}
EXPECT_EQ(out, STEER_OUTPUT_MAX, "clamp: output saturates at STEER_OUTPUT_MAX");
}
static void test_output_clamped_negative(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, -500.0f);
int16_t out = 0;
for (int i = 0; i < 500; i++) {
out = steering_pid_update(&s, 0.0f, 0.001f);
}
EXPECT_EQ(out, -STEER_OUTPUT_MAX, "clamp_neg: output saturates at -STEER_OUTPUT_MAX");
}
static void test_anti_windup(void)
{
/*
* Run with a persistent error for a long time.
* Integral should be clamped to ±STEER_INTEGRAL_MAX.
*/
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 50.0f);
for (int i = 0; i < 10000; i++) {
steering_pid_update(&s, 0.0f, 0.001f); /* actual stays 0, error = 50 */
}
EXPECT_NEAR(s.integral, STEER_INTEGRAL_MAX, 0.01f, "anti-windup: integral clamped to max");
}
static void test_anti_windup_negative(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, -50.0f);
for (int i = 0; i < 10000; i++) {
steering_pid_update(&s, 0.0f, 0.001f);
}
EXPECT_NEAR(s.integral, -STEER_INTEGRAL_MAX, 0.01f, "anti-windup-neg: integral clamped");
}
static void test_rate_limiter_single_step(void)
{
/*
* At dt=0.001s: max_step = STEER_RAMP_RATE_PER_MS * 1 = 10.
* Even if raw PID output = 400, first step can only reach 10.
*/
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 500.0f);
int16_t out = steering_pid_update(&s, 0.0f, 0.001f);
EXPECT_EQ(out, STEER_RAMP_RATE_PER_MS * 1, "rate_lim: first step bounded");
}
static void test_rate_limiter_larger_dt(void)
{
/*
* At dt=0.01s (100Hz): max_step = 10 * 10 = 100.
* First step limited to 100.
*/
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 500.0f);
int16_t out = steering_pid_update(&s, 0.0f, 0.01f);
EXPECT_EQ(out, STEER_RAMP_RATE_PER_MS * 10, "rate_lim_100hz: first step bounded");
}
static void test_rate_limiter_decreasing(void)
{
/*
* After saturating to STEER_OUTPUT_MAX, set target to 0.
* Output should decrease at most STEER_RAMP_RATE_PER_MS per ms.
*/
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 500.0f);
for (int i = 0; i < 500; i++) {
steering_pid_update(&s, 0.0f, 0.001f);
}
/* Now output = STEER_OUTPUT_MAX = 400 */
int16_t before = s.output;
steering_pid_reset(&s); /* reset integral and target */
/* output is also zeroed by reset — which is fine: the test is
really about rate limiting when driving toward 0 after a step */
(void)before;
/* Re-saturate, then drive to 0 with matching actual */
steering_pid_init(&s);
steering_pid_set_target(&s, 500.0f);
for (int i = 0; i < 500; i++) {
steering_pid_update(&s, 0.0f, 0.001f);
}
steering_pid_set_target(&s, 0.0f);
int16_t pre = s.output;
int16_t post = steering_pid_update(&s, 0.0f, 0.001f);
/* Should decrease by no more than STEER_RAMP_RATE_PER_MS per tick */
int16_t delta = pre - post;
EXPECT_TRUE(delta <= STEER_RAMP_RATE_PER_MS, "rate_lim_dec: decrease bounded per tick");
}
static void test_zero_error_zero_output(void)
{
/*
* When target == actual from the first tick, error = 0 and output stays 0.
*/
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 30.0f);
int16_t out = steering_pid_update(&s, 30.0f, 0.001f);
EXPECT_EQ(out, 0, "zero_error: no output when tracking exactly");
}
static void test_enable_disable_clears_state(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 50.0f);
for (int i = 0; i < 100; i++) {
steering_pid_update(&s, 0.0f, 0.001f);
}
/* Disable */
steering_pid_set_enabled(&s, false);
EXPECT_FALSE(s.enabled, "disable: enabled false");
EXPECT_NEAR (s.target_omega_dps, 0.0f, 1e-6f, "disable: target zeroed");
EXPECT_NEAR (s.integral, 0.0f, 1e-6f, "disable: integral zeroed");
EXPECT_EQ (s.output, 0, "disable: output zeroed");
/* Calls while disabled return 0 */
int16_t out = steering_pid_update(&s, 50.0f, 0.001f);
EXPECT_EQ(out, 0, "disable: update returns 0");
}
static void test_re_enable(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_enabled(&s, false);
steering_pid_set_enabled(&s, true);
EXPECT_TRUE(s.enabled, "re-enable: enabled true");
/* After re-enable, new target and update should work */
steering_pid_set_target(&s, 20.0f);
EXPECT_NEAR(s.target_omega_dps, 20.0f, 1e-6f, "re-enable: set_target works");
}
static void test_set_target_ignored_when_disabled(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_enabled(&s, false);
steering_pid_set_target(&s, 99.0f);
EXPECT_NEAR(s.target_omega_dps, 0.0f, 1e-6f, "disabled_target: target not set");
}
static void test_actual_omega_updated(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_update(&s, 42.5f, 0.001f);
EXPECT_NEAR(s.actual_omega_dps, 42.5f, 1e-4f, "actual_omega: stored after update");
}
static void test_get_output_matches_update(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 20.0f);
int16_t from_update = steering_pid_update(&s, 0.0f, 0.001f);
EXPECT_EQ(from_update, steering_pid_get_output(&s), "get_output: matches update return");
}
static void test_omega_scale_convention(void)
{
/*
* Verify that setting target via STEER_OMEGA_SCALE:
* omega_dps = steer * STEER_OMEGA_SCALE
* steer=100 omega=10 deg/s
*/
float omega = 100.0f * STEER_OMEGA_SCALE;
EXPECT_NEAR(omega, 10.0f, 1e-4f, "omega_scale: 100 units = 10 deg/s");
omega = -1000.0f * STEER_OMEGA_SCALE;
EXPECT_NEAR(omega, -100.0f, 1e-4f, "omega_scale: -1000 units = -100 deg/s");
}
static void test_tlm_first_call_fires(void)
{
/*
* After init, last_tlm_ms is set so that the first send_tlm fires immediately
* at now_ms = 0.
*/
steering_pid_t s;
steering_pid_init(&s);
g_tlm_count = 0;
steering_pid_send_tlm(&s, 0);
EXPECT_EQ(g_tlm_count, 1, "tlm_first: fires at ms=0 after init");
}
static void test_tlm_rate_limited(void)
{
/*
* STEER_TLM_HZ = 10 interval = 100ms.
* Two calls at ms=0 and ms=50 should only send once.
*/
steering_pid_t s;
steering_pid_init(&s);
g_tlm_count = 0;
steering_pid_send_tlm(&s, 0); /* fires (first call) */
steering_pid_send_tlm(&s, 50); /* blocked (< 100ms) */
EXPECT_EQ(g_tlm_count, 1, "tlm_rate: blocked within interval");
steering_pid_send_tlm(&s, 100); /* fires (interval elapsed) */
EXPECT_EQ(g_tlm_count, 2, "tlm_rate: fires after interval");
}
static void test_tlm_payload_target(void)
{
steering_pid_t s;
steering_pid_init(&s);
s.target_omega_dps = 25.5f; /* → target_x10 = 255 */
g_tlm_count = 0;
steering_pid_send_tlm(&s, 0);
EXPECT_EQ(g_last_tlm.target_x10, 255, "tlm_payload: target_x10 correct");
}
static void test_tlm_payload_actual(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 10.0f);
steering_pid_update(&s, 12.3f, 0.001f);
g_tlm_count = 0;
steering_pid_send_tlm(&s, 0);
/* actual = 12.3 deg/s → actual_x10 = 123 */
EXPECT_EQ(g_last_tlm.actual_x10, 123, "tlm_payload: actual_x10 correct");
}
static void test_tlm_payload_output(void)
{
steering_pid_t s;
steering_pid_init(&s);
s.output = 150;
g_tlm_count = 0;
steering_pid_send_tlm(&s, 0);
EXPECT_EQ(g_last_tlm.output, 150, "tlm_payload: output correct");
}
static void test_tlm_payload_enabled_flag(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_enabled(&s, true);
g_tlm_count = 0;
steering_pid_send_tlm(&s, 0);
EXPECT_EQ(g_last_tlm.enabled, 1u, "tlm_payload: enabled=1 when on");
steering_pid_init(&s);
steering_pid_set_enabled(&s, false);
steering_pid_send_tlm(&s, 0);
EXPECT_EQ(g_last_tlm.enabled, 0u, "tlm_payload: enabled=0 when off");
}
static void test_tlm_payload_pad_zero(void)
{
steering_pid_t s;
steering_pid_init(&s);
g_tlm_count = 0;
steering_pid_send_tlm(&s, 0);
EXPECT_EQ(g_last_tlm._pad, 0u, "tlm_payload: _pad = 0");
}
static void test_tlm_target_x10_negative(void)
{
steering_pid_t s;
steering_pid_init(&s);
s.target_omega_dps = -30.7f; /* → target_x10 = -307 */
g_tlm_count = 0;
steering_pid_send_tlm(&s, 0);
EXPECT_EQ(g_last_tlm.target_x10, -307, "tlm_payload: negative target_x10");
}
static void test_tlm_int16_clamp_positive(void)
{
steering_pid_t s;
steering_pid_init(&s);
s.target_omega_dps = 4000.0f; /* × 10 = 40000 > INT16_MAX */
g_tlm_count = 0;
steering_pid_send_tlm(&s, 0);
EXPECT_EQ(g_last_tlm.target_x10, 32767, "tlm_clamp: target_x10 clamped to INT16_MAX");
}
static void test_tlm_int16_clamp_negative(void)
{
steering_pid_t s;
steering_pid_init(&s);
s.target_omega_dps = -4000.0f; /* × 10 = -40000 < INT16_MIN */
g_tlm_count = 0;
steering_pid_send_tlm(&s, 0);
EXPECT_EQ(g_last_tlm.target_x10, (int16_t)-32768, "tlm_clamp: target_x10 clamped to INT16_MIN");
}
static void test_balance_not_disturbed_by_small_step(void)
{
/*
* A balance bot is most sensitive to sudden changes in steer_cmd.
* Verify that a large target step at t=0 only increments output by
* STEER_RAMP_RATE_PER_MS per millisecond not a step change.
* This confirms the rate limiter protects the balance PID.
*/
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 100.0f); /* large yaw demand */
/* 10ms at 1kHz */
for (int i = 0; i < 10; i++) {
int16_t pre = s.output;
int16_t post = steering_pid_update(&s, 0.0f, 0.001f);
int16_t step = post - pre;
EXPECT_TRUE(step <= STEER_RAMP_RATE_PER_MS, "balance_protect: step bounded each tick");
EXPECT_TRUE(step >= -STEER_RAMP_RATE_PER_MS, "balance_protect: neg step bounded each tick");
}
}
static void test_derivative_on_error_change(void)
{
/*
* When error changes suddenly (e.g. actual_omega jumps), the derivative
* term should add a braking contribution.
* With STEER_KD=0.05 and a 10 deg/s²-equivalent error rate change:
* d_term = 0.05 * (error1 - error0) / dt
* We just verify output changes when error changes, indicating D is active.
*/
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 20.0f);
/* Step 1: actual = 0, error = 20 */
int16_t out1 = steering_pid_update(&s, 0.0f, 0.001f);
/* Step 2: actual jumps to 20, error = 0. D will oppose. */
int16_t out2 = steering_pid_update(&s, 20.0f, 0.001f);
/* out2 should be less than out1 (derivative braking as error drops) */
EXPECT_TRUE(out2 <= out1, "derivative: braking when error shrinks");
}
static void test_reset_then_restart(void)
{
steering_pid_t s;
steering_pid_init(&s);
steering_pid_set_target(&s, 50.0f);
for (int i = 0; i < 100; i++) {
steering_pid_update(&s, 0.0f, 0.001f);
}
steering_pid_reset(&s);
EXPECT_EQ (s.output, 0, "reset_restart: output 0");
EXPECT_NEAR(s.integral, 0.0f, 1e-6f, "reset_restart: integral 0");
EXPECT_NEAR(s.target_omega_dps, 0.0f, 1e-6f, "reset_restart: target 0");
/* After reset, new target should work cleanly */
steering_pid_set_target(&s, 10.0f);
int16_t out = steering_pid_update(&s, 0.0f, 0.001f);
EXPECT_TRUE(out > 0, "reset_restart: positive output after fresh start");
}
static void test_tlm_hz_constant(void)
{
EXPECT_TRUE(STEER_TLM_HZ == 10u, "tlm_hz: STEER_TLM_HZ is 10");
}
static void test_output_max_constant(void)
{
EXPECT_TRUE(STEER_OUTPUT_MAX == 400, "output_max: STEER_OUTPUT_MAX is 400");
}
/* ---- main ---- */
int main(void)
{
printf("=== test_steering_pid ===\n\n");
test_init();
test_reset();
test_disabled_returns_zero();
test_zero_dt_returns_zero();
test_negative_dt_returns_zero();
test_proportional_only();
test_converges_to_setpoint();
test_output_clamped_to_max();
test_output_clamped_negative();
test_anti_windup();
test_anti_windup_negative();
test_rate_limiter_single_step();
test_rate_limiter_larger_dt();
test_rate_limiter_decreasing();
test_zero_error_zero_output();
test_enable_disable_clears_state();
test_re_enable();
test_set_target_ignored_when_disabled();
test_actual_omega_updated();
test_get_output_matches_update();
test_omega_scale_convention();
test_tlm_first_call_fires();
test_tlm_rate_limited();
test_tlm_payload_target();
test_tlm_payload_actual();
test_tlm_payload_output();
test_tlm_payload_enabled_flag();
test_tlm_payload_pad_zero();
test_tlm_target_x10_negative();
test_tlm_int16_clamp_positive();
test_tlm_int16_clamp_negative();
test_balance_not_disturbed_by_small_step();
test_derivative_on_error_change();
test_reset_then_restart();
test_tlm_hz_constant();
test_output_max_constant();
printf("\nResults: %d passed, %d failed\n", s_pass, s_fail);
return s_fail ? 1 : 0;
}