Skip to content

File syn_autotune.c

File List > control > syn_autotune.c

Go to the documentation of this file

#if __has_include("syn_config.h")
#include "syn_config.h"
#endif

#if !defined(SYN_USE_AUTOTUNE) || SYN_USE_AUTOTUNE

#include "../port/syn_port_system.h"
#include "../util/syn_assert.h"
#include "syn_autotune.h"

#include <string.h>

/* ── Helpers ────────────────────────────────────────────────────────────── */

#include <stdio.h>

static void apply_raw_output(SYN_AutoTune *at, int32_t output)
{
    at->current_output = output;
    if (at->ctrl->cfg.motor.set_output != NULL) {
        at->ctrl->cfg.motor.set_output(at->ctrl->cfg.motor.ctx, output);
    }
}

static void emergency_stop(SYN_AutoTune *at, SYN_AutoTune_AbortReason reason)
{
    if (at == NULL)
        return;
    at->current_output = 0;
    syn_motor_ctrl_stop(at->ctrl);
    at->state = SYN_ATUNE_ABORTED;
    at->abort_reason = reason;
}

static int32_t ramp_output(SYN_AutoTune *at, int32_t target, uint32_t elapsed)
{
    uint32_t ramp = at->cfg.ramp_ms;
    if (ramp == 0 || elapsed >= ramp)
        return target;
    return (target * (int32_t)elapsed) / (int32_t)ramp;
}

static void write_log(SYN_AutoTune *at)
{
    if (at->cfg.datalog == NULL)
        return;

    SYN_AutoTune_LogFrame frame = {.state = (uint8_t)at->state,
                                   .output = (int16_t)at->current_output,
                                   .position = at->ctrl->measured_position,
                                   .velocity = at->ctrl->measured_velocity};

    syn_datalog_write(at->cfg.datalog, SYN_ATUNE_LOG_ID, &frame, sizeof(frame));
}

static bool safety_ok(SYN_AutoTune *at, int32_t pos, int32_t velocity)
{
    uint32_t now = syn_port_get_tick_ms();

    /* Watchdog: update() must be called regularly */
    uint32_t watchdog = at->cfg.watchdog_ms;
    if (watchdog == 0)
        watchdog = at->cfg.limits.watchdog_ms;
    if (watchdog == 0)
        watchdog = 500;

    if (at->last_update_tick > 0) {
        if ((now - at->last_update_tick) > watchdog) {
            emergency_stop(at, SYN_ATUNE_ABORT_WATCHDOG);
            return false;
        }
    }

    /* Track limits */
    if (at->cfg.limits.position_min != 0 || at->cfg.limits.position_max != 0) {
        if (pos < at->cfg.limits.position_min || pos > at->cfg.limits.position_max) {
            emergency_stop(at, SYN_ATUNE_ABORT_POSITION);
            return false;
        }
    }

    /* Velocity limit */
    if (at->cfg.limits.max_velocity > 0) {
        int32_t abs_vel = (velocity < 0) ? -velocity : velocity;
        if (abs_vel > at->cfg.limits.max_velocity) {
            emergency_stop(at, SYN_ATUNE_ABORT_VELOCITY);
            return false;
        }
    }

    /* Respect motor controller soft limits */
    if (at->ctrl->cfg.position_min != 0 || at->ctrl->cfg.position_max != 0) {
        if (pos <= at->ctrl->cfg.position_min || pos >= at->ctrl->cfg.position_max) {
            emergency_stop(at, SYN_ATUNE_ABORT_SOFT_LIMIT);
            return false;
        }
    }

    return true;
}

/* ── API ────────────────────────────────────────────────────────────────── */

SYN_Status syn_autotune_init(SYN_AutoTune *at, SYN_MotorCtrl *ctrl, const SYN_AutoTune_Config *cfg)
{
    SYN_ASSERT(at != NULL);
    SYN_ASSERT(ctrl != NULL);
    SYN_ASSERT(cfg != NULL);

    memset(at, 0, sizeof(*at));
    at->cfg = *cfg;
    at->ctrl = ctrl;

    /* Apply defaults */
    if (at->cfg.settle_ms == 0)
        at->cfg.settle_ms = 2000;
    if (at->cfg.measure_ms == 0)
        at->cfg.measure_ms = 2000;
    if (at->cfg.relay_cycles == 0)
        at->cfg.relay_cycles = 3;
    if (at->cfg.watchdog_ms == 0)
        at->cfg.watchdog_ms = 500;
    if (at->cfg.ramp_ms == 0)
        at->cfg.ramp_ms = 500;
    if (at->cfg.gain_multiplier_pct == 0)
        at->cfg.gain_multiplier_pct = 100;

    /* Snapshot starting position and set default setpoint if 0 */
    at->start_position = ctrl->measured_position;
    if (at->cfg.setpoint == 0) {
        at->cfg.setpoint = at->start_position;
    }
    at->phase_start_tick = syn_port_get_tick_ms();
    at->last_update_tick = at->phase_start_tick;
    at->result.pid_scale = ctrl->cfg.pid_scale;
    if (at->result.pid_scale == 0)
        at->result.pid_scale = 8;
    at->result.ff_scale = ctrl->cfg.ff_scale;
    if (at->result.ff_scale == 0)
        at->result.ff_scale = 8;

    /* Stop the motor controller — we're taking over */
    syn_motor_ctrl_stop(ctrl);

    at->ka_p1_captured = false;
    at->ka_p2_captured = false;
    at->history_v = 0;
    at->last_check_tick = 0;

    if (at->cfg.test_output == 0) {
        at->state = SYN_ATUNE_PROBE;
        at->current_output = 5; /* Start probing at 5% */
    } else {
        at->state = SYN_ATUNE_RAMP_UP;
    }

    if (cfg->mode == SYN_ATUNE_MODE_RELAY) {
        at->above_setpoint = (ctrl->measured_position >= cfg->setpoint);
        at->osc_peak_pos = ctrl->measured_position;
        at->osc_peak_neg = ctrl->measured_position;
    }

    return SYN_OK;
}

SYN_AutoTune_State syn_autotune_update(SYN_AutoTune *at)
{
    SYN_ASSERT(at != NULL);

    if (at->state == SYN_ATUNE_DONE || at->state == SYN_ATUNE_ABORTED ||
        at->state == SYN_ATUNE_IDLE) {
        return at->state;
    }

    /* Read feedback manually (we bypassed the controller) */
    int32_t pos = at->ctrl->cfg.read_pos(at->ctrl->cfg.read_pos_ctx);
    int32_t delta = pos - at->ctrl->last_position;
    int32_t velocity = delta * (int32_t)at->ctrl->cfg.update_hz;
    at->ctrl->measured_position = pos;
    at->ctrl->measured_velocity = velocity;
    at->ctrl->last_position = pos;

    uint32_t now = syn_port_get_tick_ms();

    /* Safety checks first */
    if (!safety_ok(at, pos, velocity)) {
        write_log(at);
        return at->state; /* already aborted inside safety_ok */
    }
    at->last_update_tick = now;

    write_log(at);

    uint32_t elapsed = now - at->phase_start_tick;

    switch (at->state) {
    /* ── Probe for minimum motion output ────────────────────────── */
    case SYN_ATUNE_PROBE: {
        apply_raw_output(at, at->current_output);
        int32_t drift = pos - at->start_position;
        if (drift < 0)
            drift = -drift;

        /* If moved significantly, we found the motion threshold.
         * Using 50 counts to be sure it's not just noise/slack. */
        if (drift > 50) {
            at->cfg.test_output = (at->current_output * 120) / 100; /* 20% margin instead of 100% */
            at->state = SYN_ATUNE_BRAKING;
            at->phase_start_tick = now;
        } else if (elapsed >= 300) {
            /* Increment output every 300ms by 1% */
            at->current_output += 1;
            at->phase_start_tick = now;
            if (at->current_output > 50) {
                emergency_stop(at, SYN_ATUNE_ABORT_NO_MOTION);
            }
        }
        break;
    }

    /* ── Braking to stop between phases ────────────────────────── */
    case SYN_ATUNE_BRAKING:
        apply_raw_output(at, 0);
        if (velocity == 0 || elapsed >= 2000) {
            at->state = SYN_ATUNE_RAMP_UP;
            at->phase_start_tick = now;
            at->start_position = pos;
            if (at->cfg.mode == SYN_ATUNE_MODE_RELAY ||
                (at->cfg.mode == SYN_ATUNE_MODE_AUTO && at->result.ff_kv != 0)) {
                at->cfg.setpoint = pos;
                at->above_setpoint = true;
                at->osc_peak_pos = pos;
                at->osc_peak_neg = pos;
            }
        }
        break;

    /* ── Ramp up to test output ────────────────────────────────── */
    case SYN_ATUNE_RAMP_UP: {
        int32_t target;
        bool is_relay = (at->cfg.mode == SYN_ATUNE_MODE_RELAY) ||
                        (at->cfg.mode == SYN_ATUNE_MODE_AUTO && at->result.ff_kv != 0);

        if (is_relay) {
            target = at->above_setpoint ? -at->cfg.test_output : at->cfg.test_output;
        } else {
            target = at->cfg.test_output;
        }
        int32_t out = ramp_output(at, target, elapsed);
        apply_raw_output(at, out);

        if (elapsed >= at->cfg.ramp_ms) {
            at->phase_start_tick = now;
            if (is_relay) {
                at->state = SYN_ATUNE_RELAY;
                at->relay_output = target;
            } else {
                at->state = SYN_ATUNE_SETTLING;
            }
        }
        break;
    }

    /* ── FF ident: settling phase ──────────────────────────────── */
    case SYN_ATUNE_SETTLING:
        apply_raw_output(at, at->cfg.test_output);

        /* Adaptive point capture for Ka identification */
        if (!at->ka_p1_captured) {
            /* Trigger P1 immediately as the baseline for this phase */
            at->ka_t1 = now;
            at->ka_v1 = velocity;
            at->ka_p1_captured = true;
        } else if (!at->ka_p2_captured) {
            /* Trigger P2 when velocity has changed meaningfully from baseline
             * or after a fallback timeout. */
            int32_t dv = (velocity > at->ka_v1) ? (velocity - at->ka_v1) : (at->ka_v1 - velocity);
            if (dv > 100 || elapsed >= 1000) {
                at->ka_t2 = now;
                at->ka_v2 = velocity;
                at->ka_p2_captured = true;
                at->last_check_tick = now;
                at->history_v = velocity;
            }
        }

        /* Wait for steady-state: compare velocity with previous check */
        if (at->ka_p2_captured) {
            if (now - at->last_check_tick >= 500) {
                int32_t dv = (velocity > at->history_v) ? (velocity - at->history_v)
                                                        : (at->history_v - velocity);
                at->history_v = velocity;
                at->last_check_tick = now;

                /* Steady-state reached when dv < 5 over 500ms */
                if (dv < 5 || elapsed >= at->cfg.settle_ms * 10) {
                    at->state = SYN_ATUNE_MEASURING;
                    at->phase_start_tick = now;
                    at->velocity_sum = 0;
                    at->velocity_samples = 0;
                    at->last_check_tick = 0;
                }
            }
        }
        break;

    /* ── FF ident: measuring phase 1 ────────────────────────────── */
    case SYN_ATUNE_MEASURING:
        apply_raw_output(at, at->cfg.test_output);
        at->velocity_sum += velocity;
        at->velocity_samples++;

        if (elapsed >= at->cfg.measure_ms) {
            if (at->velocity_samples > 0) {
                at->result.steady_velocity_1 =
                    (int32_t)(at->velocity_sum / (int64_t)at->velocity_samples);
            }

            /* Transition to second measurement point (2x output) */
            at->state = SYN_ATUNE_SETTLING_2;
            at->phase_start_tick = now;
            at->velocity_sum = 0;
            at->velocity_samples = 0;
            at->last_check_tick = 0;
            at->history_v = velocity;
        }
        break;

    /* ── FF ident: settling phase 2 (2x output) ────────────────── */
    case SYN_ATUNE_SETTLING_2:
        apply_raw_output(at, at->cfg.test_output * 2);
        /* Wait for new steady-state after 2x output */
        if (elapsed >= 200) {
            if (now - at->last_check_tick >= 500) {
                int32_t dv = (velocity > at->history_v) ? (velocity - at->history_v)
                                                        : (at->history_v - velocity);
                at->history_v = velocity;
                at->last_check_tick = now;

                if (dv < 5 || elapsed >= at->cfg.settle_ms * 10) {
                    at->state = SYN_ATUNE_MEASURING_2;
                    at->phase_start_tick = now;
                }
            }
        }
        break;

    /* ── FF ident: measuring phase 2 (2x output) ────────────────── */
    case SYN_ATUNE_MEASURING_2:
        apply_raw_output(at, at->cfg.test_output * 2);
        at->velocity_sum += velocity;
        at->velocity_samples++;

        if (elapsed >= at->cfg.measure_ms) {
            if (at->velocity_samples > 0) {
                at->result.steady_velocity_2 =
                    (int32_t)(at->velocity_sum / (int64_t)at->velocity_samples);

                int32_t v1 = at->result.steady_velocity_1;
                int32_t v2 = at->result.steady_velocity_2;
                int32_t dv = (v2 > v1) ? (v2 - v1) : (v1 - v2);
                if (dv > 2) {
                    /* Slope kv = (Out2 - Out1) / (V2 - V1) */
                    int32_t dout = at->cfg.test_output; /* (2x - 1x) = 1x */
                    uint8_t ff_scale = at->ctrl->cfg.ff_scale;
                    if (ff_scale == 0)
                        ff_scale = 12;

                    while (ff_scale < 24 && (((int64_t)dout << ff_scale) / dv) < 100) {
                        ff_scale++;
                    }
                    at->result.ff_scale = ff_scale;
                    at->result.ff_kv = (int32_t)(((int64_t)dout << ff_scale) / dv);
                } else {
                }
            }

            /* Compute ff_ka if enabled */
            if (at->result.ff_kv > 0 && at->cfg.flags & SYN_ATUNE_FLAG_IDENT_KA) {
                if (at->ka_t2 > at->ka_t1) {
                    int32_t dt_ms = (int32_t)(at->ka_t2 - at->ka_t1);
                    int32_t dv = at->ka_v2 - at->ka_v1;
                    if (dt_ms > 0 && dv > 10) {
                        int32_t accel = (dv * 1000) / dt_ms; /* units/s^2 */
                        int32_t v_avg = (at->ka_v1 + at->ka_v2) / 2;

                        /* Use the identified slope to find friction offset at 0 speed */
                        /* Out = kv * v + offset → offset = Out - kv * v */
                        int32_t kv_out =
                            (int32_t)(((int64_t)v_avg * at->result.ff_kv) >> at->result.ff_scale);
                        int32_t accel_out = at->cfg.test_output - kv_out;

                        if (accel_out > 0) {
                            at->result.ff_ka =
                                (int32_t)(((int64_t)accel_out << at->result.ff_scale) / accel);
                        }
                    }
                }
            }

            /* Transition */
            at->phase_start_tick = now;
            at->start_output = at->current_output;
            if (at->cfg.flags & SYN_ATUNE_FLAG_TUNE_PID) {
                at->state = SYN_ATUNE_BRAKING;
            } else {
                at->state = SYN_ATUNE_RAMP_DOWN;
            }
        }
        break;

    /* ── Relay feedback oscillation ────────────────────────────── */
    case SYN_ATUNE_RELAY: {
        /* Hysteresis: require position to be at least this far past setpoint
         * before registering a zero-crossing. Prevents encoder noise from
         * creating fake crossings on heavy/slow systems. */
        int32_t hysteresis = at->ctrl->cfg.position_deadband * 2;
        if (hysteresis < 10)
            hysteresis = 10; /* minimum 10 counts */

        bool now_above = at->above_setpoint;
        if (at->above_setpoint) {
            if (pos < at->cfg.setpoint - hysteresis)
                now_above = false;
        } else {
            if (pos > at->cfg.setpoint + hysteresis)
                now_above = true;
        }

        /* Track peaks */
        if (pos > at->osc_peak_pos)
            at->osc_peak_pos = pos;
        if (pos < at->osc_peak_neg)
            at->osc_peak_neg = pos;

        /* Detect zero-crossing (with hysteresis) */
        if (now_above != at->above_setpoint) {
            at->half_cycles++;

            if (at->half_cycles >= 2) {
                int32_t amp = at->osc_peak_pos - at->osc_peak_neg;
                if (amp < 0)
                    amp = -amp;
                at->amplitude_sum += amp;
                at->amplitude_count++;
            }

            if ((at->half_cycles & 1) == 0 && at->last_cross_tick > 0) {
                at->period_sum += (now - at->last_cross_tick);
                at->period_count++;
            }
            if ((at->half_cycles & 1) == 0) {
                at->last_cross_tick = now;
            }

            at->osc_peak_pos = pos;
            at->osc_peak_neg = pos;
            at->above_setpoint = now_above;

            at->relay_output = now_above ? -at->cfg.test_output : at->cfg.test_output;
        }

        apply_raw_output(at, at->relay_output);

        if (at->period_count >= at->cfg.relay_cycles) {
            at->phase_start_tick = now;
            at->start_output = at->current_output;
            at->state = SYN_ATUNE_RAMP_DOWN;

            /* Compute PID gains */
            uint32_t Tu = at->period_sum / at->period_count;
            int32_t avg_amp =
                (at->amplitude_count > 0) ? at->amplitude_sum / at->amplitude_count : 1;
            int32_t half_amp = avg_amp / 2;
            if (half_amp == 0)
                half_amp = 1;

            at->result.Tu_ms = Tu;
            uint8_t scale = at->ctrl->cfg.pid_scale;
            if (scale == 0)
                scale = 8;

            /* Auto-scale Ku to avoid underflow */
            int32_t Ku = 0;
            while (scale < 24) {
                int64_t Ku_num = (int64_t)4 * at->cfg.test_output * 113 * ((int64_t)1 << scale);
                int64_t Ku_den = (int64_t)355 * half_amp;
                if (Ku_den == 0)
                    Ku_den = 1;
                Ku = (int32_t)(Ku_num / Ku_den);
                if (Ku >= 100 || scale >= 20)
                    break;
                scale++;
            }
            at->result.pid_scale = scale;
            at->result.Ku = Ku;

            int32_t Tu_i = (int32_t)Tu;
            switch (at->cfg.method) {
            case SYN_ATUNE_ZN_CLASSIC:
                /* Kp = 0.6*Ku, Ki = 1.2*Ku/Tu, Kd = 3*Ku*Tu/40 */
                at->result.kp = (int32_t)((int64_t)Ku * 60 / 100);
                at->result.ki = (int32_t)((int64_t)Ku * 120 / Tu_i);
                at->result.kd = (int32_t)((int64_t)Ku * 3 * Tu_i / (40L * 1000L));
                break;
            case SYN_ATUNE_ZN_NO_OVERSHOOT:
                at->result.kp = (int32_t)((int64_t)Ku * 33 / 100);
                at->result.ki = (int32_t)((int64_t)Ku * 66 / Tu_i);
                at->result.kd = (int32_t)((int64_t)Ku * 11 * Tu_i / 100000);
                break;
            case SYN_ATUNE_TYREUS_LUYBEN:
                /* Kp = Ku/2.2, Ki = Ku/(2.2*Tu), Kd = Ku*Tu/6.3
                 * For Ki with large Tu: multiply first to avoid truncation */
                at->result.kp = (int32_t)((int64_t)Ku * 100 / 220);
                if (Tu_i > 0) {
                    at->result.ki = (int32_t)((int64_t)Ku * 10000 / ((int64_t)220 * Tu_i));
                }
                at->result.kd = (int32_t)((int64_t)Ku * Tu_i / 6300);
                break;
            }

            /* Apply safety multiplier */
            if (at->cfg.gain_multiplier_pct != 100) {
                at->result.kp =
                    (int32_t)((int64_t)at->result.kp * at->cfg.gain_multiplier_pct / 100);
                at->result.ki =
                    (int32_t)((int64_t)at->result.ki * at->cfg.gain_multiplier_pct / 100);
                at->result.kd =
                    (int32_t)((int64_t)at->result.kd * at->cfg.gain_multiplier_pct / 100);
            }
        }
        break;
    }

    /* ── Ramp down to zero ─────────────────────────────────────── */
    case SYN_ATUNE_RAMP_DOWN: {
        uint32_t ramp = at->cfg.ramp_ms;
        if (ramp == 0) {
            apply_raw_output(at, 0);
            syn_motor_ctrl_stop(at->ctrl);
            at->state = SYN_ATUNE_DONE;
            break;
        }

        uint32_t e = (elapsed > ramp) ? ramp : elapsed;
        int32_t out = (at->start_output * (int32_t)(ramp - e)) / (int32_t)ramp;

        if (elapsed >= ramp || (at->start_output > 0 && out <= 0) ||
            (at->start_output < 0 && out >= 0)) {
            apply_raw_output(at, 0);
            syn_motor_ctrl_stop(at->ctrl);
            at->state = SYN_ATUNE_DONE;
        } else {
            apply_raw_output(at, out);
            if (elapsed % 100 == 0) {
            }
        }
        break;
    }

    default:
        break;
    }

    return at->state;
}

void syn_autotune_apply(SYN_AutoTune *at)
{
    SYN_ASSERT(at != NULL);
    if (at->state != SYN_ATUNE_DONE)
        return;

    at->ctrl->cfg.pid_kp = at->result.kp;
    at->ctrl->cfg.pid_ki = at->result.ki;
    at->ctrl->cfg.pid_kd = at->result.kd;
    at->ctrl->cfg.ff_kv = at->result.ff_kv;
    at->ctrl->cfg.ff_ka = at->result.ff_ka;
    at->ctrl->cfg.pid_scale = at->result.pid_scale;
}

void syn_autotune_abort(SYN_AutoTune *at)
{
    if (at == NULL)
        return;
    SYN_ASSERT(at != NULL);
    emergency_stop(at, SYN_ATUNE_ABORT_USER);
}

SYN_Status syn_autotune_start(SYN_AutoTune *at, SYN_MotorCtrl *ctrl,
                              const SYN_AutoTune_Limits *limits, SYN_AutoTune_Flags flags,
                              uint16_t gain_multiplier)
{
    SYN_ASSERT(at != NULL);
    SYN_ASSERT(ctrl != NULL);
    SYN_ASSERT(limits != NULL);

    SYN_AutoTune_Config cfg = {
        .mode = SYN_ATUNE_MODE_AUTO,
        .flags = flags,
        .test_output = 0, /* Auto-probe */
        .limits = *limits,
        .relay_cycles = 4,
        .method = SYN_ATUNE_TYREUS_LUYBEN,
        .watchdog_ms = limits->watchdog_ms,
        .gain_multiplier_pct = gain_multiplier,
    };

    return syn_autotune_init(at, ctrl, &cfg);
}

void syn_autotune_calc_relay_gains(int32_t Ku, uint32_t Tu_ms, SYN_AutoTune_Method method,
                                   uint16_t multiplier_pct, int32_t *kp, int32_t *ki, int32_t *kd)
{
    if (!kp || !ki || !kd || Tu_ms == 0)
        return;

    int32_t p = 0, i = 0, d = 0;
    int32_t tu = (int32_t)Tu_ms;

    switch (method) {
    case SYN_ATUNE_ZN_CLASSIC:
        p = (Ku * 60) / 100;
        i = (p * 2000) / tu;
        d = (p * tu) / 8000;
        break;
    case SYN_ATUNE_TYREUS_LUYBEN:
        p = (Ku * 45) / 100;
        i = (p * 1000) / ((tu * 22) / 10);
        d = (p * tu) / 6300;
        break;
    case SYN_ATUNE_ZN_NO_OVERSHOOT:
    default:
        p = (Ku * 20) / 100;
        i = (p * 2000) / tu;
        d = (p * tu) / 3000;
        break;
    }

    if (multiplier_pct != 100 && multiplier_pct > 0) {
        p = (p * multiplier_pct) / 100;
        i = (i * multiplier_pct) / 100;
        d = (d * multiplier_pct) / 100;
    }

    *kp = p;
    *ki = i;
    *kd = d;
}

#endif /* SYN_USE_AUTOTUNE */