saltylab-firmware/src/pid_schedule.c
sl-controls 8592361095 feat: PID gain scheduling for speed-dependent balance (Issue #550)
Implements a speed-dependent PID gain scheduler that interpolates Kp/Ki/Kd
across a configurable table of velocity breakpoints, replacing the fixed
single-gain PID used previously.

Changes:
- include/pid_flash.h: add pid_sched_entry_t (16-byte entry), pid_sched_flash_t
  (128-byte record at 0x0807FF40), pid_flash_load_schedule(), pid_flash_save_all()
  (atomic single-sector erase for both schedule and single-PID records)
- src/pid_flash.c: implement load_schedule and save_all; single erase covers
  both records at 0x0807FF40 (schedule) and 0x0807FFC0 (single PID)
- include/pid_schedule.h: API header -- init, get_gains, apply, set/get table,
  flash_save, active_band_idx, get_default_table
- src/pid_schedule.c: linear interpolation between sorted speed-band entries;
  integrator reset on band transition; default 3-band table (0/0.3/0.8 m/s)
- include/jlink.h: add SCHED_GET (0x0C), SCHED_SET (0x0D), SCHED_SAVE (0x0E)
  commands; TLM_SCHED (0x85); jlink_tlm_sched_t; JLinkSchedSetBuf;
  sched_get_req, sched_save_req fields in JLinkState; include pid_flash.h
- src/jlink.c: dispatch SCHED_GET/SET/SAVE; implement jlink_send_sched_telemetry,
  jlink_get_sched_set; add JLinkSchedSetBuf static buffer
- test/test_pid_schedule.c: 48 unit tests -- all passing (gcc host build)

Flash layout (sector 7):
  0x0807FF40  pid_sched_flash_t (128 bytes) -- schedule
  0x0807FFC0  pid_flash_t       ( 64 bytes) -- single PID (existing)

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

175 lines
5.1 KiB
C

#include "pid_schedule.h"
#include "pid_flash.h"
#include <string.h>
#include <math.h> /* fabsf */
/* ---- Default 3-band table ---- */
static const pid_sched_entry_t k_default_table[3] = {
{ .speed_mps = 0.00f, .kp = 40.0f, .ki = 1.5f, .kd = 1.2f },
{ .speed_mps = 0.30f, .kp = 35.0f, .ki = 1.0f, .kd = 1.0f },
{ .speed_mps = 0.80f, .kp = 28.0f, .ki = 0.5f, .kd = 0.8f },
};
/* ---- Active table ---- */
static pid_sched_entry_t s_bands[PID_SCHED_MAX_BANDS];
static uint8_t s_num_bands = 0u;
static uint8_t s_active_band = 0u; /* lower-bracket index of last call */
static uint8_t s_prev_band = 0xFFu; /* sentinel: forces integrator reset on first apply */
/* ---- sort helper (insertion sort — table is small, ≤6 entries) ---- */
static void sort_bands(void)
{
for (uint8_t i = 1u; i < s_num_bands; i++) {
pid_sched_entry_t key = s_bands[i];
int8_t j = (int8_t)(i - 1u);
while (j >= 0 && s_bands[j].speed_mps > key.speed_mps) {
s_bands[j + 1] = s_bands[j];
j--;
}
s_bands[j + 1] = key;
}
}
/* ---- pid_schedule_init() ---- */
void pid_schedule_init(void)
{
pid_sched_entry_t tmp[PID_SCHED_MAX_BANDS];
uint8_t n = 0u;
if (pid_flash_load_schedule(tmp, &n)) {
/* Validate entries minimally */
bool ok = true;
for (uint8_t i = 0u; i < n; i++) {
if (tmp[i].kp < 0.0f || tmp[i].kp > 500.0f ||
tmp[i].ki < 0.0f || tmp[i].ki > 50.0f ||
tmp[i].kd < 0.0f || tmp[i].kd > 50.0f ||
tmp[i].speed_mps < 0.0f) {
ok = false;
break;
}
}
if (ok) {
memcpy(s_bands, tmp, n * sizeof(pid_sched_entry_t));
s_num_bands = n;
sort_bands();
s_active_band = 0u;
return;
}
}
/* Fall back to built-in default */
memcpy(s_bands, k_default_table, sizeof(k_default_table));
s_num_bands = 3u;
s_active_band = 0u;
s_prev_band = 0xFFu;
}
/* ---- pid_schedule_get_gains() ---- */
void pid_schedule_get_gains(float speed_mps, float *kp, float *ki, float *kd)
{
float spd = fabsf(speed_mps);
if (s_num_bands == 0u) {
*kp = k_default_table[0].kp;
*ki = k_default_table[0].ki;
*kd = k_default_table[0].kd;
return;
}
/* Clamp below first entry */
if (spd <= s_bands[0].speed_mps) {
s_active_band = 0u;
*kp = s_bands[0].kp;
*ki = s_bands[0].ki;
*kd = s_bands[0].kd;
return;
}
/* Clamp above last entry */
uint8_t last = s_num_bands - 1u;
if (spd >= s_bands[last].speed_mps) {
s_active_band = last;
*kp = s_bands[last].kp;
*ki = s_bands[last].ki;
*kd = s_bands[last].kd;
return;
}
/* Find bracket [i-1, i] where bands[i-1].speed <= spd < bands[i].speed */
uint8_t i = 1u;
while (i < s_num_bands && s_bands[i].speed_mps <= spd) i++;
/* Now bands[i-1].speed_mps <= spd < bands[i].speed_mps */
s_active_band = (uint8_t)(i - 1u);
float dv = s_bands[i].speed_mps - s_bands[i - 1u].speed_mps;
float t = (dv > 0.0f) ? (spd - s_bands[i - 1u].speed_mps) / dv : 0.0f;
*kp = s_bands[i - 1u].kp + t * (s_bands[i].kp - s_bands[i - 1u].kp);
*ki = s_bands[i - 1u].ki + t * (s_bands[i].ki - s_bands[i - 1u].ki);
*kd = s_bands[i - 1u].kd + t * (s_bands[i].kd - s_bands[i - 1u].kd);
}
/* ---- pid_schedule_apply() ---- */
void pid_schedule_apply(balance_t *b, float speed_mps)
{
float kp, ki, kd;
pid_schedule_get_gains(speed_mps, &kp, &ki, &kd);
b->kp = kp;
b->ki = ki;
b->kd = kd;
/* Reset integrator on band transition to prevent windup spike */
if (s_active_band != s_prev_band) {
b->integral = 0.0f;
s_prev_band = s_active_band;
}
}
/* ---- pid_schedule_set_table() ---- */
void pid_schedule_set_table(const pid_sched_entry_t *entries, uint8_t n)
{
if (n == 0u) n = 1u;
if (n > PID_SCHED_MAX_BANDS) n = PID_SCHED_MAX_BANDS;
memcpy(s_bands, entries, n * sizeof(pid_sched_entry_t));
s_num_bands = n;
s_active_band = 0u;
s_prev_band = 0xFFu;
sort_bands();
}
/* ---- pid_schedule_get_table() ---- */
void pid_schedule_get_table(pid_sched_entry_t *out_entries, uint8_t *out_n)
{
memcpy(out_entries, s_bands, s_num_bands * sizeof(pid_sched_entry_t));
*out_n = s_num_bands;
}
/* ---- pid_schedule_get_num_bands() ---- */
uint8_t pid_schedule_get_num_bands(void)
{
return s_num_bands;
}
/* ---- pid_schedule_flash_save() ---- */
bool pid_schedule_flash_save(float kp_single, float ki_single, float kd_single)
{
return pid_flash_save_all(kp_single, ki_single, kd_single,
s_bands, s_num_bands);
}
/* ---- pid_schedule_active_band_idx() ---- */
uint8_t pid_schedule_active_band_idx(void)
{
return s_active_band;
}
/* ---- pid_schedule_get_default_table() ---- */
void pid_schedule_get_default_table(pid_sched_entry_t *out_entries, uint8_t *out_n)
{
memcpy(out_entries, k_default_table, sizeof(k_default_table));
*out_n = 3u;
}