The problem
An EV must hold a commanded speed or torque while the load changes with slope, wind and payload. Open-loop PWM gives a speed that drops under load; feedback control measures the actual speed and corrects the command.
Objectives
- Read a quadrature encoder with a hardware timer and compute speed every 10 ms
- Implement a discrete PID with output limits and anti-windup
- Tune the gains and compare step responses with open-loop control
- Log speed, set-point and duty to a PC and plot them
What you need
From the EV Embedded Development Kit
- STM32F103C8T6 microcontroller unit (Arm Cortex-M3)
- Motor driver for PWM motor-control experiments
- Analog potentiometer input
- Serial UART interface
- 16×2 LCD
Software
- STM32CubeIDE with HAL TIM (PWM and encoder mode), ADC and UART
- Embedded C
- Python with pyserial, pandas and matplotlib for step-response plots
Extra parts
- A small brushed DC gear motor with a quadrature encoder, within the motor driver's rating — The kit has no motor and no speed sensor; the encoder gives the feedback the loop needs.
Key concepts
- Encoder mode
- An STM32 general-purpose timer can count quadrature edges in hardware (TIM_ENCODERMODE_TI12), so the counter value gives position and its change per period gives speed.
- Discrete PID
- Each control period the output is Kp times error plus Ki times the summed error times the period plus Kd times the change in error divided by the period.
- Integrator windup
- When the duty saturates at 100%, the integral keeps growing and causes overshoot later; anti-windup stops integrating while the output is saturated.
- Step response metrics
- Rise time, overshoot, settling time and steady-state error describe how well the loop follows a sudden change in set-point.
Step-by-step guide
- 1
Set requirements
Choose a speed range inside the motor's rating and write measurable targets: under 10% overshoot, settling within 0.5 s and steady-state error under 2% for a set-point step, plus recovery within 1 s after a load step. Read the encoder counts per motor revolution and the gear ratio from the motor's datasheet and compute counts per output-shaft revolution. Work out the speed resolution this gives at a 10 ms sample period, because it limits how well you can control low speeds.
Checkpoint: Written targets and the encoder resolution in counts per output-shaft revolution.
- 2
Configure the board
Use the DIP switches to enable the motor driver, potentiometer and LCD. In CubeMX set the 72 MHz clock, one timer for 20 kHz PWM on the driver input, a second timer in encoder mode on the two encoder pins brought out on the kit headers, ADC on the pot, a UART at 115200 baud and a third timer for a 100 Hz control interrupt. Check the kit schematic so the encoder pins do not clash with another enabled block.
Checkpoint: Turning the motor shaft by hand changes the encoder counter in both directions.
- 3
Measure speed
Start the encoder timer with HAL_TIM_Encoder_Start. Every 10 ms read the counter, subtract the previous value as signed 16-bit integers, which handles counter wraparound automatically, and convert the count difference to rpm using counts per revolution and the 10 ms period. At low speed the count per period is small and the reading jumps, so average two or three samples or measure over a longer window, and note the resulting delay.
Checkpoint: At a fixed duty, the computed rpm is steady and matches a count of revolutions over a timed minute.
- 4
Characterise the open loop
Before closing the loop, learn how the motor behaves. Apply duty steps of 30%, 50% and 70% from standstill and log speed at 100 Hz over UART. In Python estimate the steady-state gain in rpm per per cent duty and the time constant from the time to reach 63% of the final speed. Check whether the gain is the same at each step; friction usually makes it lower at small duty. These numbers give you a sensible starting point for the gains.
Checkpoint: A plot and a first-order model with gain and time constant.
- 5
Write the PID
Implement the PID in the 100 Hz interrupt with the output clamped to 0–100%. Take the derivative of the measured speed rather than of the error, so set-point changes do not kick the output, and use conditional integration for anti-windup: stop integrating while the output is saturated in the same direction as the error. Integer or fixed-point gains are cheapest; float also works at this rate but is emulated on the FPU-less Cortex-M3, so measure the interrupt time either way.
Checkpoint: The PID function passes PC-side unit tests against a hand-calculated example.
- 6
Tune the gains
Start with proportional gain only and increase it until the response is fast with a slight oscillation. Then add integral gain to remove the steady-state error, and a small derivative gain to reduce overshoot. Use the first-order model from the open-loop step to sanity-check the values, for example by simulating them in Python first. Log every trial with its gains so the tuning path is in your report, and stop when the targets are met.
Checkpoint: A step from 30% to 70% of maximum speed meets the overshoot and settling targets.
- 7
Set-point from the pot and display
Map the potentiometer to the allowed speed range so it becomes the set-point, with a small dead-band at zero. Show set-point, measured rpm and duty on the LCD a few times per second. Stream all three values over UART as a compact CSV line from the control interrupt's data, sending from the main loop so the interrupt stays short. Use the Python script to save each run to a file named with its gains and date.
Checkpoint: Speed follows the pot and the LCD and PC log agree.
- 8
Test load rejection
Apply a light, repeatable load to the output shaft, such as a friction pad pressed with a fixed weight or a clip-on disc that adds inertia. Run the same set-point with open-loop control at the duty that gave that speed unloaded, then with the PID. Compare the speed drop and the recovery for both. Repeat several times so you can show the spread, and keep the load gentle so you stay within the motor driver's rating.
Checkpoint: Plots show the PID recovering to set-point while the open-loop speed stays low.
- 9
Report
Write up the open-loop model, the tuning steps, the final gains, and the step and load-disturbance responses with their measured rise time, overshoot, settling time and steady-state error. Present a table comparing open-loop control and PID, and include the measured interrupt execution time. Explain why you chose the final gains and what limits performance, such as encoder resolution at low speed or duty saturation at high speed.
Checkpoint: A report with tables of rise time, overshoot, settling and error for each controller.
Testing & validation
- Encoder speed verified against a timed revolution count
- Step response metrics against the written targets
- Load-rejection comparison of open loop and PID
- Control interrupt execution time measured
Expected results
A DC motor that holds a set speed under changing load using encoder feedback and a tuned PID, with logged step and load-disturbance responses compared with open-loop control.
Take it further
- Add relay-feedback auto-tuning
- Add a soft-start ramp on the set-point
- Port the loop to a FreeRTOS task and measure jitter
Viva and review questions
How do you handle encoder counter wraparound?
Subtract the previous count from the current one as signed 16-bit integers; the subtraction wraps correctly as long as the motor moves less than half the counter range per period.
Why derivative on measurement?
A sudden set-point change makes the error jump, which would make derivative on error kick the output; the measured speed changes smoothly.
What is anti-windup?
Stopping the integrator from accumulating while the output is saturated, so the loop does not overshoot when it leaves saturation.
Why might you avoid floating point here?
The Cortex-M3 has no FPU, so float maths is emulated in software and takes many more cycles than integer or fixed-point code.