From spinning to standing still
The previous note ended with a motor spinning — a small triumph of velocity control. This one is about the opposite problem, and somehow the harder one: making the motor stand still at a commanded angle and stay there when I push on it. Velocity control answers "how fast"; position control answers "where", and "where" turns out to need a stiffer loop and a calmer hand on the gains.
The bench is identical to last time. Same ESP32, same DRV8302, same salvaged BLDC with eleven pole pairs of mystery. The only thing that changed is the controller mode and three lines of tuning.1
The PID, made boring on purpose
SimpleFOC stacks position control on top of velocity control: an outer P loop on angle produces a velocity setpoint, which feeds the inner PI loop on speed. That cascade is the whole trick. The outer loop is just proportional — no integral, no derivative — and the inner loop does the work of making that proportional command stick.
"Outer loop sets the destination. Inner loop drives the car."
Two gains do almost all the personality. The outer-P is how aggressive the motor is about chasing the angle. The inner-PI is how stiff that chase feels under load. Crank the outer P and you get a snappy, overshooting motor. Crank the inner P and you get a buzzy, twitchy one. Crank both and you get smoke.
The sketch and the gotcha
The code is shorter than the explanation. Switch the control mode to angle and add an outer-P gain — everything else is the same boilerplate from §01.
#include <SimpleFOC.h>
BLDCMotor motor = BLDCMotor(11);
BLDCDriver3PWM driver = BLDCDriver3PWM(25, 26, 27, 12);
MagneticSensorI2C sensor = MagneticSensorI2C(AS5600_I2C);
void setup() {
Serial.begin(115200);
sensor.init();
motor.linkSensor(&sensor);
driver.voltage_power_supply = 12;
driver.init();
motor.linkDriver(&driver);
// angle mode == cascade: outer P(angle) → inner PI(velocity)
motor.controller = MotionControlType::angle;
motor.P_angle.P = 18.0f; // outer: how hard we chase θ*
motor.PID_velocity.P = 0.18f; // inner: how stiff under load
motor.PID_velocity.I = 2.0f;
motor.LPF_velocity.Tf = 0.01f; // low-pass on derived ω, keeps things calm
motor.voltage_limit = 4; // lower than spin-mode; we hold, not race
motor.velocity_limit = 20; // rad/s ceiling for outer-loop commands
motor.useMonitoring(Serial);
motor.init();
motor.initFOC();
Serial.println(F("ready · angle mode"));
}
void loop() {
motor.loopFOC();
motor.move(3.14159); // hold at π rad
motor.monitor();
}
The gotcha that took me an hour: I left velocity_limit at its default and the outer loop happily commanded ω* = 200 rad/s on every small angle error, which the inner loop dutifully tried to deliver, which the driver dutifully tried to source, which the bench-top supply dutifully refused. The motor buzzed. Capping the velocity is what made the cascade civilised.2
Step response and the satisfying click
With the cascade configured, I commanded a step from 0 to 1 rad and logged the encoder for 600 ms. The trace is what every control textbook promises and most benches don't deliver: a clean rise, a small overshoot, and a settle inside a hundred milliseconds.
The green dashed line is the 95% band; the trace crosses it once and stays. More importantly — and this is the part that actually felt like holding a pose — I could nudge the motor with a finger and feel it push back, then return to the same angle when I let go. That tactile detail is the whole reason for closing the loop.
The reported velocity sitting at 0.00 while position holds at the target — to three decimal places, without commentary — is what success looks like in this mode. Boring serial output is the goal.