Fast Robots

ECE 5160 Spring 2026

← Back to Labs

Lab 6: Yaw PID Control

Due: TBD

Overview

This lab implements a PID controller to regulate the robot's yaw angle to a commanded setpoint using IMU feedback. A Python notebook triggers the experiment over Bluetooth, sends the controller parameters (setpoint, gains, loop period, duration, PWM limits), and subscribes to telemetry streamed back from the Artemis. The on-board controller runs a full PID loop on yaw angle, logs P/I/D terms and motor commands, and returns the data for post-processing.

The report covers Bluetooth communication, PID structure and gain selection, sampling time and timing behavior, experimental results (yaw vs time, motor commands, and controller terms), and the anti-windup strategy used to keep the controller stable even when the motors saturate.

If any media isn't working, you can view it using this link: https://github.com/cobylai/ECE5160/tree/main/assets/lab6 .

Prelab — Bluetooth Communication

The yaw PID experiment is controlled over BLE using string characteristics. On the Artemis, a BLE service exposes a receive characteristic for commands and a transmit characteristic for telemetry. In ble_arduino.ino, the read_data() function checks whether the RX string characteristic was written and, if so, calls handle_command() to parse and execute the incoming command.

Commands are encoded as a type plus a payload. For yaw control, Python sends a command with numeric type 13. In the Arduino code this is handled by the YAW_PID_START case in the command switch statement, while on the Python side the same numeric command is named CMD.RUN_PID. Although the symbolic names differ, the shared numeric ID keeps the protocol consistent.

C++
// BLE_RECEIVE_AND_DISPATCH
void read_data() {
  if (rx_characteristic_string.written()) {
    handle_command();
  }
}

void handle_command() {
  robot_cmd.set_cmd_string(rx_characteristic_string.value(),
                           rx_characteristic_string.valueLength());
  int cmd_type = -1;
  bool success = robot_cmd.get_command_type(cmd_type);
  if (!success) return;

  switch (cmd_type) {
    case YAW_PID_START: {
      // parse yaw setpoint and PID parameters, then run controller
      break;
    }
  }
}

Inside the YAW_PID_START case, the Artemis parses the payload fields containing the yaw setpoint, PID gains, run duration, PWM limits, and loop period. It then executes a blocking PID loop that logs telemetry locally into arrays and later streams the logs back as BLE notifications. During this blocking run, inbound commands are not processed again until the handler returns, so live tuning within a single run is not supported in the current implementation.

C++
// YAW_PID_COMMAND_PARSE (simplified)
case YAW_PID_START: {
  int   setpoint_deg = 0;
  float kp = 0, ki = 0, kd = 0;
  int   duration_ms = 0;
  int   min_pwm = 0, max_pwm = 0;
  int   loop_period_ms = 10;

  if (!robot_cmd.get_next_value(setpoint_deg)) break;
  if (!robot_cmd.get_next_value(kp)) break;
  robot_cmd.get_next_value(ki);
  robot_cmd.get_next_value(kd);
  robot_cmd.get_next_value(duration_ms);
  robot_cmd.get_next_value(min_pwm);
  robot_cmd.get_next_value(max_pwm);
  robot_cmd.get_next_value(loop_period_ms);

  // run yaw PID loop here and log telemetry
}

On the Python side, the BLE helper connects to the Artemis, subscribes to the TX characteristic for notifications, and registers a handler that parses each telemetry string into numeric arrays. The notebook then sends the CMD.RUN_PID command with the chosen gains and parameters, waits for the run to finish, and uses the logged data for plotting.

Python
# PYTHON_BLE_COMMAND_AND_TELEMETRY (simplified)
ble = get_ble_controller()
ble.connect()

data_time = []
data_yaw = []
data_kp_term = []
data_ki_term = []
data_windup_term = []
data_kd_term = []
data_total_term = []
data_motor_pwm = []

def data_handler(_uuid, payload: bytearray):
    s = payload.decode().strip()
    parsed = parse_pid_data(s)
    if parsed is None:
        return
    (t_s, ready, angle_deg,
     kp_t, ki_t, windup_t,
     kd_t, total_t, pwm) = parsed
    data_time.append(t_s)
    data_yaw.append(angle_deg)
    data_kp_term.append(kp_t)
    data_ki_term.append(ki_t)
    data_windup_term.append(windup_t)
    data_kd_term.append(kd_t)
    data_total_term.append(total_t)
    data_motor_pwm.append(pwm)

setpoint_deg = 90
kp, ki, kd = 1.5, 0.6, 0.0
duration_ms = 4000
min_pwm, max_pwm = 85, 130

payload = f"{int(setpoint_deg)}|{kp}|{ki}|{kd}|{duration_ms}|{min_pwm}|{max_pwm}"
ble.start_notify(ble.uuid["RX_STRING"], data_handler)
ble.send_command(CMD.RUN_PID, payload)

PID Controller Design and Tuning

The yaw controller is implemented as a full PID controller with runtime-configurable gains. The proportional term drives the heading error toward zero, the integral term compensates for bias and friction, and the derivative term (if enabled) adds damping based on the rate of change of the error. In the final configuration for this lab, the derivative gain is kept at zero so the controller acts as a PI controller, which avoids amplifying IMU noise.

The control law uses the yaw angle estimated from the gyro as the feedback signal. The error is computed as the difference between the desired yaw setpoint (in degrees) and the filtered yaw angle. The proportional, integral, and derivative terms are combined into a total control effort that is later mapped into a signed PWM command for the motors.

C++
// YAW_PID_CONTROLLER (simplified)
float error = yaw_setpoint_deg - used_angle_deg;
float kp_term = yaw_kp * error;

// integrate error with anti-windup (see later section)
yaw_integral_unclamped += error * dt_s;

float kd_term = yaw_kd * (error - yaw_prev_error) / dt_s;

float u_unclamped = kp_term + yaw_ki * yaw_integral_unclamped + kd_term;
float u_clamped = u_unclamped;
if (u_clamped >  yaw_max_pwm) u_clamped =  yaw_max_pwm;
if (u_clamped < -yaw_max_pwm) u_clamped = -yaw_max_pwm;

A typical tuned set of gains used in the lab notebook is Kp = 1.5, Ki = 0.6, and Kd = 0.0 with a yaw setpoint of about 90° and PWM limits between 85 and 130. With these values the robot turns briskly toward the desired heading, eliminates steady-state error without excessive overshoot, and avoids the noise sensitivity that can appear when derivative action is added on the raw yaw signal.

Range and Sampling Time

The yaw controller's timing is based on a measured loop period using microsecond timestamps. At each iteration, the code computes dt from micros() and uses this value to integrate the gyro rate and update the yaw estimate. The loop also enforces a nominal period using a delay, and waits for the IMU to report that new data is ready before reading sensor values.

C++
// YAW_LOOP_TIMING (simplified)
unsigned long now_us = micros();
float dt_s = ((float)(now_us - yaw_last_time_us)) / 1000000.0f;
if (dt_s <= 0.0f) {
  dt_s = yaw_loop_period_ms / 1000.0f;
}
yaw_last_time_us = now_us;

// wait briefly for new IMU data
unsigned long wait_start = millis();
while (!myICM.dataReady() && (millis() - wait_start) < 50) {}

if (myICM.dataReady()) {
  myICM.getAGMT();
  float gz = myICM.gyrZ();  // deg/s
  yaw_gyro_deg += gz * dt_s;
}

delay((unsigned long)yaw_loop_period_ms);

Nominally the controller is configured for a loop period on the order of 10 ms, but the effective sampling time can be longer when the IMU is slow to provide new data or when BLE logging at the end of the run introduces overhead. In the analysis notebook, the data_time array is used to reconstruct the actual sampling interval, which can be compared against the desired period to understand timing jitter and its impact on controller performance.

Experimental Results — Yaw vs Time

The BLE telemetry includes the yaw angle, P/I/D terms, total control effort, and signed motor PWM command at each controller iteration. In the combined plot below, the top panel shows θ(t) with the setpoint overlay, the middle panel shows the signed PWM command, and the bottom panel shows the individual P/I/D contributions, which together make it easy to analyze rise time, overshoot, settling time, and steady-state error.

Combined yaw, PWM, and PID-term plots vs time
Figure: Combined plot of yaw angle with setpoint, signed PWM command, and P/I/D term breakdown vs time for a yaw step.

In the final tuned run, the yaw angle tracks the 90° setpoint with a smooth rise and minimal overshoot. The PWM command briefly saturates near the start of the maneuver, then drops to smaller values as the robot approaches the target heading. The integral term removes residual bias due to friction, while the proportional term dominates the transient response.

Video: Yaw PID run converging to the commanded heading.

5000-Level Requirement — Integrator Wind-Up Protection

Because the yaw maneuver often starts with a large heading error, the controller can easily saturate the motor command. If the integral term were allowed to accumulate error during saturation, it would grow very large and cause overshoot and long settling times once the system leaves saturation. To prevent this, the controller implements an anti-windup scheme that both gates integration and clamps the integral magnitude based on the available PWM range.

C++
// YAW_INTEGRAL_WINDUP_PROTECTION
bool saturated_high = (u_pwm >= yaw_max_pwm);
bool saturated_low  = (u_pwm <= -yaw_max_pwm);

bool pushing_high = (u_unclamped >  yaw_max_pwm && error > 0.0f);
bool pushing_low  = (u_unclamped < -yaw_max_pwm && error < 0.0f);

bool allow_integral = !((saturated_high && pushing_high) ||
                        (saturated_low  && pushing_low));

if (allow_integral) {
  yaw_integral = yaw_integral_unclamped;
  if (yaw_ki > 0.0f) {
    float i_limit = ((float)yaw_max_pwm) / yaw_ki;
    if (yaw_integral >  i_limit) yaw_integral =  i_limit;
    if (yaw_integral < -i_limit) yaw_integral = -i_limit;
  }
}

The telemetry also logs a dedicated windup term, which helps visualize when the anti-windup logic is active. In the plots, this term spikes when the controller hits its PWM limits and then quickly decays once the error decreases and the actuator leaves saturation, confirming that the anti-windup scheme is working as intended.