/* * 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 #include /* 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 #include #include 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; }