Closed-loop yaw-rate controller that converts Jetson Twist.angular.z to a differential wheel speed offset using IMU gyro Z as feedback. - include/steering_pid.h + src/steering_pid.c: PID with anti-windup (integral clamped to ±200 counts) and rate limiter (10 counts/ms max output change) to protect balance PID from sudden steering steps. JLINK_TLM_STEERING (0x8A) telemetry at 10 Hz. - include/mpu6000.h + src/mpu6000.c: expose yaw_rate (board_gz) in IMUData so callers have direct bias-corrected gyro Z feedback. - include/jlink.h + src/jlink.c: add JLINK_TLM_STEERING (0x8A), jlink_tlm_steering_t (8 bytes), jlink_send_steering_tlm(). - test/test_steering_pid.c: 78 unit tests (host build with gcc), all passing. Usage (main loop): steering_pid_set_target(&s, jlink_state.steer * STEER_OMEGA_SCALE); int16_t steer_out = steering_pid_update(&s, imu.yaw_rate, dt); motor_driver_update(&motor, balance_cmd, steer_out, now_ms); Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
619 lines
19 KiB
C
619 lines
19 KiB
C
/*
|
||
* 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;
|
||
}
|