saltylab-firmware/test/test_slope_estimator.c
sl-controls 5f03e4cbef feat: Tilt compensation for slopes (Issue #600)
Adds a slow-adapting terrain slope estimator (IIR tau=5s) that decouples
the robot's balance offset from genuine ground incline.  The balance
controller subtracts the slope estimate from measured pitch so the PID
balances around the slope surface rather than absolute vertical.

- include/slope_estimator.h + src/slope_estimator.c: first-order IIR
  filter clamped to ±15°; JLINK_TLM_SLOPE (0x88) telemetry at 1 Hz
- include/jlink.h + src/jlink.c: add JLINK_TLM_SLOPE (0x88),
  jlink_tlm_slope_t (4 bytes), jlink_send_slope_tlm()
- include/balance.h + src/balance.c: integrate slope_estimator into
  balance_t; update, reset on tilt-fault and disarm
- test/test_slope_estimator.c: 35 unit tests, all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 15:04:58 -04:00

422 lines
13 KiB
C

/*
* test_slope_estimator.c — Unit tests for slope estimator (Issue #600).
*
* Build (host, no hardware):
* gcc -I include -I test/stubs -DTEST_HOST -lm \
* -o /tmp/test_slope src/slope_estimator.c test/test_slope_estimator.c
*
* All tests are self-contained; no HAL, no ADC, no UART required.
* slope_estimator.c calls jlink_send_slope_tlm() which is stubbed below.
*/
/* ---- Stubs ---- */
/* Prevent jlink.h from pulling in HAL / pid_flash types */
#define JLINK_H
#include <stdint.h>
#include <stdbool.h>
/* Minimal jlink_tlm_slope_t matching the real definition */
typedef struct __attribute__((packed)) {
int16_t slope_x100;
uint8_t active;
uint8_t _pad;
} jlink_tlm_slope_t;
/* Capture last transmitted tlm for inspection */
static jlink_tlm_slope_t g_last_tlm;
static int g_tlm_count = 0;
void jlink_send_slope_tlm(const jlink_tlm_slope_t *tlm)
{
g_last_tlm = *tlm;
g_tlm_count++;
}
/* ---- Include implementation directly ---- */
#include "../src/slope_estimator.c"
/* ---- Test framework ---- */
#include <stdio.h>
#include <math.h>
#include <string.h>
static int g_pass = 0;
static int g_fail = 0;
#define ASSERT(cond, msg) do { \
if (cond) { g_pass++; } \
else { g_fail++; printf("FAIL [%s:%d] %s\n", __FILE__, __LINE__, msg); } \
} while(0)
#define ASSERT_NEAR(a, b, tol, msg) \
ASSERT(fabsf((a)-(b)) <= (tol), msg)
/* ---- Tests ---- */
static void test_init_zeroes_state(void)
{
slope_estimator_t se;
/* dirty the memory first */
memset(&se, 0xAB, sizeof(se));
slope_estimator_init(&se);
ASSERT(se.estimate_deg == 0.0f, "init: estimate_deg == 0");
ASSERT(se.enabled == true, "init: enabled == true");
}
static void test_get_when_disabled_returns_zero(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
/* Force a non-zero estimate by running a few ticks, then disable */
slope_estimator_update(&se, 10.0f, 0.1f);
slope_estimator_set_enabled(&se, false);
ASSERT(slope_estimator_get_deg(&se) == 0.0f,
"get while disabled must return 0");
}
static void test_update_when_disabled_no_change(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
slope_estimator_set_enabled(&se, false);
slope_estimator_update(&se, 10.0f, 0.01f);
ASSERT(se.estimate_deg == 0.0f,
"update while disabled must not change estimate");
}
static void test_update_zero_dt_no_change(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
slope_estimator_update(&se, 5.0f, 0.0f);
ASSERT(se.estimate_deg == 0.0f,
"update with dt=0 must not change estimate");
}
static void test_update_negative_dt_no_change(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
slope_estimator_update(&se, 5.0f, -0.001f);
ASSERT(se.estimate_deg == 0.0f,
"update with dt<0 must not change estimate");
}
static void test_single_step_converges_toward_input(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
/* One update with dt=0.01s, pitch=10 deg */
slope_estimator_update(&se, 10.0f, 0.01f);
/* alpha = 0.01 / (5.0 + 0.01) ≈ 0.002; new estimate ≈ 0 + 0.002*10 = 0.02 */
ASSERT(se.estimate_deg > 0.0f,
"estimate should move toward positive pitch");
ASSERT(se.estimate_deg < 10.0f,
"estimate should not jump to full pitch in one step");
}
static void test_iir_reaches_63pct_in_one_tau(void)
{
/* Run estimator at 1 kHz for exactly SLOPE_TAU_S seconds */
slope_estimator_t se;
slope_estimator_init(&se);
const float pitch = 10.0f;
const float dt = 0.001f;
const int steps = (int)(SLOPE_TAU_S / dt); /* 5000 steps */
for (int i = 0; i < steps; i++) {
slope_estimator_update(&se, pitch, dt);
}
/* IIR should be at ≈63.2% of the step */
float expected_lo = pitch * 0.60f;
float expected_hi = pitch * 0.66f;
ASSERT(se.estimate_deg >= expected_lo && se.estimate_deg <= expected_hi,
"IIR should reach ~63% of step at t=tau");
}
static void test_iir_reaches_63pct_at_100hz(void)
{
/* Same tau, but at 100 Hz */
slope_estimator_t se;
slope_estimator_init(&se);
const float pitch = 8.0f;
const float dt = 0.01f;
const int steps = (int)(SLOPE_TAU_S / dt); /* 500 steps */
for (int i = 0; i < steps; i++) {
slope_estimator_update(&se, pitch, dt);
}
float expected_lo = pitch * 0.60f;
float expected_hi = pitch * 0.66f;
ASSERT(se.estimate_deg >= expected_lo && se.estimate_deg <= expected_hi,
"IIR 100 Hz: should reach ~63% at t=tau");
}
static void test_positive_clamp(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
/* Drive with a huge pitch for a long time — estimate must clamp at SLOPE_MAX_DEG */
for (int i = 0; i < 100000; i++) {
slope_estimator_update(&se, 90.0f, 0.001f);
}
ASSERT_NEAR(se.estimate_deg, SLOPE_MAX_DEG, 0.001f,
"estimate must clamp at +SLOPE_MAX_DEG");
}
static void test_negative_clamp(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
for (int i = 0; i < 100000; i++) {
slope_estimator_update(&se, -90.0f, 0.001f);
}
ASSERT_NEAR(se.estimate_deg, -SLOPE_MAX_DEG, 0.001f,
"estimate must clamp at -SLOPE_MAX_DEG");
}
static void test_reset_zeroes_estimate(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
/* Build up some estimate */
for (int i = 0; i < 1000; i++) {
slope_estimator_update(&se, 12.0f, 0.001f);
}
ASSERT(se.estimate_deg > 0.1f, "pre-reset: estimate should be non-zero");
slope_estimator_reset(&se);
ASSERT(se.estimate_deg == 0.0f, "post-reset: estimate should be zero");
/* enabled state must be preserved */
ASSERT(se.enabled == true, "reset must not change enabled flag");
}
static void test_reset_preserves_disabled_state(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
slope_estimator_set_enabled(&se, false);
slope_estimator_reset(&se);
ASSERT(se.enabled == false, "reset must not re-enable disabled estimator");
}
static void test_balance_setpoint_compensation(void)
{
/*
* Simulate: robot on 5-deg slope, pitch = 5 deg, slope estimate converges.
* After convergence, tilt_corrected = pitch - slope ≈ 0.
* Verify that the effective PID error (setpoint=0, tilt_corrected≈0) is near 0.
*/
slope_estimator_t se;
slope_estimator_init(&se);
const float slope_deg = 5.0f;
const float dt = 0.001f;
const int steps = (int)(3.0f * SLOPE_TAU_S / dt); /* 3 tau = ~95% converged */
for (int i = 0; i < steps; i++) {
/* Robot holds steady on slope — pitch stays constant */
slope_estimator_update(&se, slope_deg, dt);
}
float est = slope_estimator_get_deg(&se);
float tilt_corrected = slope_deg - est;
/* After 3 tau, estimate should be within 5% of slope */
ASSERT(est >= slope_deg * 0.90f, "3-tau estimate >= 90% of slope");
/* tilt_corrected should be close to zero — minimal PID error */
ASSERT(fabsf(tilt_corrected) < slope_deg * 0.10f,
"corrected tilt error < 10% of slope after 3 tau");
}
static void test_fast_tilt_not_tracked(void)
{
/*
* Simulate: robot starts balanced (pitch≈0), then tips to 20 deg suddenly
* for 100 ms (balance intervention), then returns to 0.
* The slope estimate should not move significantly.
*/
slope_estimator_t se;
slope_estimator_init(&se);
const float dt = 0.001f;
/* 2 s of stable balancing at pitch ≈ 0 */
for (int i = 0; i < 2000; i++) {
slope_estimator_update(&se, 0.0f, dt);
}
float before = se.estimate_deg;
/* 100 ms spike to 20 deg (fast tilt) */
for (int i = 0; i < 100; i++) {
slope_estimator_update(&se, 20.0f, dt);
}
/* Return to 0 for another 100 ms */
for (int i = 0; i < 100; i++) {
slope_estimator_update(&se, 0.0f, dt);
}
float after = se.estimate_deg;
/* 200ms / 5000ms = 4% of tau — estimate should move < 1 deg */
ASSERT(fabsf(after - before) < 1.0f,
"fast tilt transient should not significantly move slope estimate");
}
static void test_slope_change_tracks_slowly(void)
{
/* Robot transitions from flat (0°) to 10° slope at t=0.
* After 1 tau, estimate should be ~63% of 10 = ~6.3 deg. */
slope_estimator_t se;
slope_estimator_init(&se);
const float dt = 0.001f;
const int steps = (int)(SLOPE_TAU_S / dt);
for (int i = 0; i < steps; i++) {
slope_estimator_update(&se, 10.0f, dt);
}
ASSERT(se.estimate_deg >= 6.0f && se.estimate_deg <= 6.5f,
"slope change: 1-tau estimate should be 6.0-6.5 deg");
}
static void test_symmetry_negative_slope(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
const float dt = 0.001f;
const int steps = (int)(SLOPE_TAU_S / dt);
for (int i = 0; i < steps; i++) {
slope_estimator_update(&se, -8.0f, dt);
}
/* Should be ~63% of -8 */
ASSERT(se.estimate_deg <= -4.0f && se.estimate_deg >= -5.5f,
"negative slope: estimate should converge symmetrically");
}
static void test_enable_disable_toggle(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
/* Build estimate */
for (int i = 0; i < 2000; i++) {
slope_estimator_update(&se, 10.0f, 0.001f);
}
float val = se.estimate_deg;
ASSERT(val > 0.1f, "estimate built before disable");
/* Disable: estimate frozen */
slope_estimator_set_enabled(&se, false);
slope_estimator_update(&se, 10.0f, 0.001f);
ASSERT(se.estimate_deg == val, "estimate frozen while disabled");
/* get returns 0 when disabled */
ASSERT(slope_estimator_get_deg(&se) == 0.0f, "get returns 0 while disabled");
/* Re-enable: estimate resumes */
slope_estimator_set_enabled(&se, true);
slope_estimator_update(&se, 10.0f, 0.001f);
ASSERT(se.estimate_deg > val, "estimate resumes after re-enable");
}
static void test_tlm_rate_limiting(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
g_tlm_count = 0;
/* Call at 1000 Hz for 3 seconds → should send 3 TLMs (at 1 Hz rate limit) */
for (uint32_t ms = 0; ms < 3000; ms++) {
slope_estimator_send_tlm(&se, ms);
}
/* First call at ms=0 sends, then 1000ms, 2000ms → 3 total */
ASSERT(g_tlm_count == 3, "TLM rate-limited to SLOPE_TLM_HZ (1 Hz)");
}
static void test_tlm_payload_encoding(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
/* Force a known estimate */
se.estimate_deg = 7.5f;
se.enabled = true;
g_tlm_count = 0;
g_last_tlm.slope_x100 = 0;
/* Trigger a TLM by advancing time enough */
slope_estimator_send_tlm(&se, 0u);
ASSERT(g_tlm_count == 1, "TLM sent");
ASSERT(g_last_tlm.slope_x100 == 750, "7.5 deg -> slope_x100 = 750");
ASSERT(g_last_tlm.active == 1u, "active flag set when enabled");
}
static void test_tlm_disabled_active_flag(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
slope_estimator_set_enabled(&se, false);
se.estimate_deg = 3.0f;
g_tlm_count = 0;
slope_estimator_send_tlm(&se, 0u);
ASSERT(g_tlm_count == 1, "TLM sent when disabled");
ASSERT(g_last_tlm.active == 0u, "active flag clear when disabled");
/* slope_x100 reflects stored estimate but get() returns 0 */
ASSERT(g_last_tlm.slope_x100 == 300, "slope_x100 encodes stored estimate");
}
static void test_tlm_clamped_to_int16(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
/* Manually set beyond int16 range for test */
se.estimate_deg = 400.0f; /* 400 * 100 = 40000, within int16 */
se.enabled = true;
g_tlm_count = 0;
slope_estimator_send_tlm(&se, 0u);
ASSERT(g_last_tlm.slope_x100 == 32767, "large estimate clamped to INT16_MAX");
}
static void test_multiple_resets_idempotent(void)
{
slope_estimator_t se;
slope_estimator_init(&se);
slope_estimator_reset(&se);
slope_estimator_reset(&se);
ASSERT(se.estimate_deg == 0.0f, "multiple resets: still zero");
ASSERT(se.enabled == true, "multiple resets: still enabled");
}
/* ---- main ---- */
int main(void)
{
printf("=== test_slope_estimator ===\n");
test_init_zeroes_state();
test_get_when_disabled_returns_zero();
test_update_when_disabled_no_change();
test_update_zero_dt_no_change();
test_update_negative_dt_no_change();
test_single_step_converges_toward_input();
test_iir_reaches_63pct_in_one_tau();
test_iir_reaches_63pct_at_100hz();
test_positive_clamp();
test_negative_clamp();
test_reset_zeroes_estimate();
test_reset_preserves_disabled_state();
test_balance_setpoint_compensation();
test_fast_tilt_not_tracked();
test_slope_change_tracks_slowly();
test_symmetry_negative_slope();
test_enable_disable_toggle();
test_tlm_rate_limiting();
test_tlm_payload_encoding();
test_tlm_disabled_active_flag();
test_tlm_clamped_to_int16();
test_multiple_resets_idempotent();
printf("\nResults: %d passed, %d failed\n", g_pass, g_fail);
return g_fail ? 1 : 0;
}