FOC Reference 06 Control loops

The current loop

Everything up to here has been preparation. Clarke and Park turned three moving sinusoids into two steady numbers so that a PI controller could work on them. This is the controller.

It is also the last place in a drive where the theory is genuinely clean. The tuning below is exact, requires no trial and error, and comes straight from two numbers on the motor’s datasheet. Then reality arrives — a bridge that cannot switch instantly, an angle that is slightly wrong, a DC link that runs out — and the interesting part of the job is knowing which of those is hurting you.

One axis of the current loop: PI with proportional and integral paths, decoupling feedforward, the vector voltage limit, and the anti-windup path back to the integrator. error feedforward v q measured i q anti-windup: how much was clipped decoupling ω e (L d i d + λ m ) i q * demand + k p = L · ω c + voltage limit on the vector machine R + sL k i / s = R · ω c / s
The PI and the plant are the obvious part. The decoupling feedforward and the anti-windup path are the two that get left out — and a loop without them still looks like it works until the machine is fast or the demand is large.

The plant is an inductor

In the rotor frame, one axis of the machine looks like this:

Ignore the last term for a moment. What is left is a resistor in series with an inductor:

A first-order lag with a pole at . For the reference machine used throughout this site — , — that pole sits at 265 Hz, and the axis takes 600 µs to respond on its own.

Tuning is not trial and error

Take a PI controller and write it as a single zero over an integrator:

Now put the controller zero exactly on the plant pole. Set , and the pole and zero cancel:

The open loop is a pure integrator, so the closed loop is

a first-order lag with a time constant you chose. Setting gives a closed-loop bandwidth of exactly :

No overshoot, no oscillation, no tuning session. Both gains fall out of the machine’s own resistance and inductance and the bandwidth you asked for.

On the bench A salient machine has , so the two axes want different gains — for the reference machine, the q axis wants 57% more than the d axis. One shared set of gains is a very common shortcut, and it costs you matched response between the axes. The site’s tuning function returns a pair for exactly this reason.

What limits the bandwidth you can ask for

The derivation above assumes the controller is continuous. It is not — it runs once per PWM period, and the voltage it computes is not applied until the next one. That delay is invisible in the algebra and very visible on a scope.

The working rule is a tenth of the PWM frequency. At 20 kHz that means about 2 kHz, and asking for much more buys ringing rather than speed. Drag the bandwidth slider past 2 kHz in the bench below and watch the overshoot figure climb: nothing in the tuning changed, only the assumption that the loop is fast compared with its own update rate.

The bench

Everything below runs the same simulator the rest of this site uses: a PMSM, a three-phase bridge, and the controller derived above. Each change re-runs the drive from rest, so you can change one thing and see precisely what it cost.

The faint dashed trace is the same drive with every non-ideality switched off. The gap between the solid line and the ghost is the penalty.

Demand
Tuning
Break it
Closed-loop current step, 48 V DC link, 20 kHz PWM. Reference machine: 0.35 Ω, 210/330 µH, 4 pole pairs. Start with Bandwidth, then break it with Deadtime and Angle error.

Things worth trying

Every number below is what this model actually produces, not a rule of thumb.

Sweep the bandwidth. 250 Hz gives a 1350 µs rise; 500 Hz gives 650 µs; 1 kHz gives 300 µs. Each doubling of halves the rise time, exactly as the algebra says it should. Push on to 4 kHz — a fifth of the switching frequency — and 4.6% of overshoot appears out of nowhere. Nothing about the tuning changed; the loop simply stopped being fast compared with its own update rate.

On the bench Above about 1.5 kHz the measurement itself runs out of resolution: the bench samples once per PWM period, so a rise time of 175 µs is only three and a half samples. That is not an artefact of the model — a real drive logging one sample per period has exactly the same problem, and it is why current-loop step responses get measured on an oscilloscope rather than in firmware.

Turn off decoupling and set the speed to 4000 rpm. At standstill it changes nothing. At 4000 rpm the rise time goes from 300 µs to 1050 µs and the loop settles at 9.10 A instead of 10.04 A — a 9% steady-state error the controller never removes, because it is fighting a back-EMF that grows with speed. This is the classic “tuned beautifully on the bench, sags in the application” failure.

Add deadtime, then reduce the demand. At 2000 rpm with 2% deadtime, the ripple is about 0.6 A. Now drop the demand from 12 A to 3 A: the ripple is still about 0.6 A. The deadtime error is a fixed 0.96 V — 2% of the 48 V link — so it is 3.5% of the voltage you wanted at 12 A and 7.2% of it at 3 A. It does not scale down with the operating point, which is precisely why deadtime compensation matters at low speed and is nearly irrelevant at high speed.

Add angle error. The controller keeps reporting that reached its target, because in its frame it did. Watch Peak |id| instead: at 20° there is 3.6 A sitting on an axis nobody asked for. What happens to the torque, though, is more interesting than it first looks — see below.

Turn off anti-windup, set speed 4000 rpm, demand 40 A, inertia ×100. Now the back-EMF plus exceeds the DC link, so the controller is saturated for the whole hold and the drive delivers about 22 A rather than the 40 A asked for.

Watch Release fall. With back-calculation the current lets go 300 µs after the demand is removed. Without it, the integrator has spent 11 ms accumulating against a limit it could never pass, and the answer is never — the current is still flowing at the end of the record, the phase currents are still swinging at full amplitude, and the bridge is still saturated. Compare the solid trace with the ghost and the failure is unmissable.

This is the one that gets misdiagnosed most often. It looks like too much gain, so the usual response is to detune the loop, which makes the drive slower without touching the actual fault.

On the bench The heavy inertia is not a trick to make the demo work — it is what makes the demo honest. The bare reference rotor is 12 g·cm², light enough that it accelerates out of saturation before the integrator has time to wind up. Any real load is heavier, and windup is a real-load problem.

An aside: angle error costs less than you would think

The obvious model of an angle error says torque falls as , because only the q-axis component makes torque. Run it on the bench and the numbers do not agree:

producedTorque, salient machineTorque,
10°−1.82 A100.8%98.4%98.5%
20°−3.60 A98.0%93.6%94.0%
45°−7.35 A75.8%69.5%70.7%

The non-salient column matches almost exactly, as it must. The reference machine does not — and at 10° it produces more torque than a perfectly aligned drive.

The reason is the reluctance term. A positive angle error puts the current vector ahead of where the controller thinks it is, which lands — a negative d-axis current. On a machine with , negative produces positive reluctance torque:

At small angles the reluctance gain very nearly cancels the loss.

Two things follow, and they point in opposite directions. Encouragingly, a salient machine is more forgiving of a small angle error than the textbook formula suggests. Less encouragingly, the torque reading will not tell you your angle is wrong. At 20° this drive is down 2% on torque — indistinguishable from measurement noise — while carrying 3.6 A of unrequested current that heats the machine and weakens its field.

If you take one diagnostic habit from this page: to find an angle error, look at , never at torque.

This is also the seed of MTPA. If negative makes free torque, the right question is not “how do I keep at zero” but “what is the optimum for this ” — which is a later page.

Decoupling

The term dropped earlier comes back now. The two axes are coupled, and the magnets add a back-EMF that grows without bound with speed:

Left alone, all three appear to the controllers as disturbances that grow with speed, so a loop tuned at standstill degrades steadily as the machine spins up. Feeding them forward costs two multiplies:

The back-EMF term dominates the other two by an order of magnitude at any real speed. If you implement only one of the three, implement that one.

On the bench Add the feedforward before the output saturation, not after. If it is added after the clamp, the controller saturates without knowing it has, and the anti-windup never fires — a bug that only shows up at high speed, where the feedforward is large.

Anti-windup

A current loop hits its voltage limit constantly: on every large step, and any time the machine approaches base speed. What the integrator does during those periods decides whether the recovery is graceful.

Back-calculation is the standard answer. Compute the unsaturated output, clamp it, and feed the difference back into the integrator:

float unsat = kp * error + integral + feedforward;
float out   = clampf(unsat, -v_limit, +v_limit);

/* Unwind by exactly as much as the clamp threw away. */
integral += (ki * error + k_aw * (out - unsat)) * dt;

The tracking gain is not a free parameter

k_aw is a tracking gain, , and the standard choice sets the tracking time constant to the integral time . So:

which for this tuning is just the plant pole again.

It is worth seeing why that value and not another. While saturated, the integrator stops growing when the two terms in the update cancel:

With this settles at — the integrator parks exactly at the saturation boundary, ready to resume the instant the limit releases. Choose much smaller and the integrator still runs away, just more slowly, and the anti-windup is decorative rather than functional. That is a genuinely easy mistake: the code looks correct, the term is present, and the failure only appears under sustained saturation.

Limit the vector, not the axes

One more trap, and it is the subtlest thing on this page.

The two axes are not limited independently. The inverter constrains the magnitude of the vector they form to , so the sequence has to be: compute both axes, limit the vector once, then unwind both integrators against what actually got through.

Clamping each axis to on its own and calling that anti-windup does nothing at all. With that limit on each axis, the per-axis clamp almost never trips, while the vector limit trips constantly — so the back-calculation term sees out == unsat, contributes zero, and the integrators wind up exactly as if there were no anti-windup in the code.

/* Both axes first... */
float vd_unsat = kp_d * e_d + integral_d + ff_d;
float vq_unsat = kp_q * e_q + integral_q + ff_q;

/* ...then ONE limit on the vector they form... */
float vd = vd_unsat, vq = vq_unsat;
limit_voltage(&vd, &vq, VDC * ONE_OVER_SQRT3);

/* ...then unwind both against what actually got through. */
integral_d += (ki_d * e_d + k_aw_d * (vd - vd_unsat)) * dt;
integral_q += (ki_q * e_q + k_aw_q * (vq - vq_unsat)) * dt;

The bug is invisible in review, invisible in a normal step response, and only appears when the drive is held against its voltage limit — at which point it looks like the anti-windup gain is wrong rather than unreachable.

In code

The whole current loop, once per PWM period:

void current_loop(float id, float iq, float id_ref, float iq_ref,
                  float we, float dt, float *vd, float *vq)
{
    /* Feedforward: cancel the speed-dependent terms. */
    const float ff_d = -we * Lq * iq;
    const float ff_q =  we * (Ld * id + flux_m);

    *vd = pi_update(&pi_d, id_ref - id, dt, ff_d);
    *vq = pi_update(&pi_q, iq_ref - iq, dt, ff_q);

    /* Never ask the bridge for more than it has. */
    limit_voltage(vd, vq, VDC * ONE_OVER_SQRT3);
}

Four multiplies, two PI updates and a limiter. The hard part was never the arithmetic — it was knowing what to put in pi_d and pi_q, and that came from two numbers on the datasheet.

What to take away

  • and is the whole tuning. If you are tuning a current loop by hand, you are missing or .
  • Bandwidth is capped by the update rate, at roughly . Past that, more gain buys ringing.
  • Decoupling makes the loop behave the same at speed as at rest. The back-EMF term is the one that matters.
  • Anti-windup is not optional — the loop saturates routinely, not exceptionally — and it has to act on the vector limit with a tracking gain of . Get either detail wrong and the code looks right while doing nothing.
  • Deadtime and angle error both degrade the drive without making the controller report anything wrong. To find an angle error look at ; torque will not tell you, and on a salient machine it barely moves at all.

Next: where the angle comes from when there is no encoder.