Field Notes · Nº 002 · Junho 2026
Field Notes · 002 — Position / PID

Closing the Loop

Position control — making the motor hold an angle and hit targets on command.

by · edgy9 min read/simpleFOCBLDCESP32position controlPID
Date
2026 · 06 · 12
Bench
ESP32 · DRV8302
Sensor
AS5600 · I²C
Status
Holding
§01

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

§02

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.

FIG. 1 — Cascade: position outer · velocity inner

θ

dθ/dt

θ* target

θ (AS5600)

P (outer)

ω*

ω (derived)

PI (inner)

FOC + driver

BLDC

"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.

§03

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.

position_hold.inocpp
#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

§04

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.

SCOPE — position step responselive
θ (rad)t (s) →

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.

/dev/ttyUSB0 — serial monitor115200 baud
> initFOC() MOT: Init MOT: Align sensor. MOT: PP check: [ok] MOT: Zero elec. angle: 2.41 MOT: [ok] Ready. ready · angle mode target = 3.1416 rad | pos = 3.1418 | vel = 0.01 target = 3.1416 rad | pos = 3.1417 | vel = 0.00 target = 3.1416 rad | pos = 3.1416 | vel = 0.00

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.

End of file · 002

References & Links

  1. SimpleFOC — angle (position) control referencedocs.simplefoc.com
  2. Cascade control — outer position, inner velocity (Wikipedia)wikipedia
  3. AS5600 12-bit magnetic position sensorams-osram.com
  4. Ziegler–Nichols tuning, applied loosely to a motor loopreference