Field Notes · Nº 002 · Maio 2026
Field Notes · 002 — Closed-loop / Sensor

Closing the Loop

Two long bench days, six software bugs that weren't the blocker, and one piece of double-sided tape that was. AS5600 + MKS Dual FOC v3.2 Plus on closed-loop FOC.

by · edgy16 min read/SimpleFOCBLDCESP32MKS Dual FOCAS5600closed-loopFOCdebugging
Date
2026 · 05 · 30
Bench
MKS Dual FOC v3.2 · EG2133
Sensor
AS5600 · I²C
Status
Spinning
§01

The sensor arrives, finally

The previous note ended with a floppy-spindle motor stuttering through open-loop rotation under an MKS Dual FOC v3.2 Plus, an AS5600 on order, and a pole-pair count I was about 80 % sure of. A few days later the encoder and its diametric magnet arrived. The new brushless motors were still in transit, but the floppy spindle had earned its keep, so I stayed with it and started wiring up the encoder.

The encoder needs a magnet on the shaft. I cut a 6 mm disc of double-sided tape, stuck it to the shaft, pressed the magnet on. It looked centred. (It wasn't, quite. That comes back in §07.)

Wired the AS5600 onto the second I²C bus, SDA on GPIO 19, SCL on 18. The first cold boot returned ESP_ERR_INVALID_STATE on every read. arduino-esp32 3.x's new i2c-ng driver needs the hardware abstraction layer (HAL) settled before Wire.begin; the empirical floor is 800 ms. A delay(800) after Serial.begin and the bus came up clean.1

motor.linkSensor, motor.init, motor.initFOC. The boot banner printed:

/dev/ttyUSB0 — initFOC after the sensor arrived115200 baud
MOT: Init MOT: Enable driver. MOT: Align sensor. MOT: sensor dir: CW MOT: PP check: OK! MOT: Zero elec. angle: 5.02 MOT: Ready.

MOT: PP check: OK! is the library's own pole-pair confirmation, run as part of initFOC. The number it landed on was 10. The "is it 9 or is it 10" tension that §001 closed on resolved itself in one line. Ten it was.

I sent T2. The motor sat there. I sent T2 again. Still nothing.

§02

The motor that lied, and the first bug

Here was the problem. The serial monitor said the motor was spinning. shaft_velocity = -4.885 rad/s. The bench said otherwise: the shaft was visibly stationary. The number in the snapshot was frozen across commands: same -4.885 after T0, same after a wire wiggle, same after a reboot if I asked too fast.

I genuinely talked myself into "maybe it is spinning, just imperceptibly". It wasn't. After a few minutes of staring at numbers that refused to agree with the bench, I had to admit:

"The motor is actually not spinning"

That was the start of two long bench days where the closed-loop torque-voltage tests would lock the rotor into one of two or three stable positions about 90° apart, telemetry would lie, and every fix would produce a new failure mode. The motor that had spun jerkily but obediently under open loop was now perfectly capable of refusing to move while reporting that it had.

The first real evidence came off a scope, not a serial port. I had set the voltage limit to L8 and then L12. The monitor reported the value changing; the motor's torque, as far as I could feel by hand, did not change at all. I noticed because I was holding a scope probe between two phases:

"I can see the positive and negative pulses, but the bursts of the PWM cycle do not change as I change the voltage. They keep the same duty cycle. Am I missing something here?"

I was not. SimpleFOC has three voltage caps with the same suggestive name, none propagating to each other. The driver's had defaulted to 1 V at boot, so every PWM duty I commanded was clipped to 1 V the moment it hit setPwm. Setting motor.voltage_limit = 8 at runtime updated the field in motor, did nothing to the PID's already-snapshotted copy, and did nothing at all to the driver. The monitor faithfully reported the value I'd set; the rotor faithfully ignored it. The fix was a Commander callback that syncs all three:

bug-1-fix.inocpp
void doLimit(char *cmd) {
  commander.scalar(&motor.voltage_limit, cmd);
  // The three caps don't propagate — each is snapshotted at motor.init().
  // This was bug #1: driver clipped every phase to 1 V, silently.
  motor.PID_velocity.limit = motor.voltage_limit;  // PID output cap
  driver.voltage_limit     = motor.voltage_limit;  // hard clip in setPwm()
}

The comment is a tombstone. After the fix, open-loop torque came back and responded to the limit. Closed loop still did not spin. Bug fixed, problem not.

More bugs surfaced over the next few hours, all the same shape: a field that didn't propagate, a flag cached before the hardware had settled, a custom routine that disagreed with the library about where the electrical angle should sit. Each was real, each fix shipped to the sketch, and none of them spun the rotor in closed loop. Flash, stare at the scope, fresh hypothesis, motor stays put.

By the end of the first long bench day the spike had grown to 1700 lines and 19 single-letter Commander commands (T L P C I V R E Z D K S Y A Q X G N F B), each one added to inspect one more failure mode. The rotor was idle-stable, the telemetry no longer frozen, and T>0 still produced wrong-direction spin. The handoff I wrote that night was short:

"Let's just collect all the learnings we have done because it's not working. We likely should take a step back and follow the steps on https://docs.simplefoc.com."

That was the end of the spike.

§03

Open loop is a trap (again)

§001 ended on the realisation that open-loop spinning proves almost nothing about closed-loop. That bill came due here. Every time a bug got fixed and the motor still wouldn't spin, the next hypothesis was "it must be alignment" or "the sensor direction" or "the PID gains". And every time, the argument that ruled those out was "but open loop works, so the modulation and driver are fine, so it has to be in the sensor chain".

That logic is wrong. SimpleFOC computes electrical_angle differently for the two cases. Open loop: electrical_angle = _normalizeAngle(shaft_angle × pole_pairs), where shaft_angle is integrated from the commanded target, with no sensor, no alignment, no zero_electric_angle. Closed loop: electrical_angle = sensor_direction × pole_pairs × sensor.getAngle() - zero_electric_angle. Same library, two completely different code paths to the same variable.

So "open loop works" tells you nothing about anything that touches the sensor, the alignment routine, or the offset. Every hypothesis I'd ruled out had been ruled out by evidence that did not transfer, and I'd spent an entire day in that loop without noticing.

What broke me out of it was a question from the bench, not from the code:

"But why would the open loop work in that case?"

Asked the right way, the question answers itself. The open loop works because it is not the same chain. Once that landed, the spike was over.

§04

The docs-canonical restart

I decided to start fresh, with lean code lifted directly from the docs. The new sketch was about 525 lines and was written from scratch following SimpleFOC's practical guides in order, with the source URL beside each block. No diagnostic ladder. No five overlapping alignment routines. No phase_resistance. The setup looked like the docs because it was the docs.

v2-setup.inocpp
void setup() {
  Serial.begin(115200);
  delay(800);  // arduino-esp32 3.x i2c-ng: HAL must settle before Wire.begin

  Wire.begin(I2C_SDA, I2C_SCL, I2C_HZ);
  sensor.init(&Wire);
  motor.linkSensor(&sensor);

  driver.voltage_power_supply = VBUS_V;
  driver.init();
  motor.linkDriver(&driver);

  // Current sense — order is load-bearing (see aside)
  currentSense.linkDriver(&driver);
  currentSense.init();
  motor.linkCurrentSense(&currentSense);

  // controller / PID / LPF seeds, lifted from the docs
  motor.controller     = MotionControlType::velocity;
  motor.PID_velocity.P = 0.1f;
  motor.PID_velocity.I = 0.5f;
  motor.LPF_velocity.Tf = 0.001f;

  motor.useMonitoring(Serial);
  motor.init();
  motor.characteriseMotor(2.0f);   // measure R, Ld, Lq in firmware
  motor.initFOC();
}

Two things from that setup are worth dwelling on. The first is motor.characteriseMotor(2.0f): a library function that drives DC pulses on the d-axis to measure phase resistance, then reads the L/R step response for inductance. The v1 spike had a hand-rolled R command that did the same thing in 80 lines of buggy setPwm writes. The library function had been there the whole time. It returned:

/dev/ttyUSB0 — characteriseMotor on the new sketch115200 baud
--- characterising motor (keep still) --- MOT: Meas R.. MOT: Est. R: 8.49 MOT: Meas L... MOT: Ld [mH]: 5.39 MOT: Lq [mH]: 5.47 --- characterisation done ---

Ld ≈ Lq means surface-mounted magnets with no saliency, exactly what a floppy spindle should be, and R = 8.49 Ω line-to-line lines up with the bench multimeter's ~7.5 Ω per phase plus board losses. Numbers I could trust, measured by the firmware in less time than a manual sweep would have taken.

The second is that the order of the current-sense calls is load-bearing. The low-side sense hooks its ADC (analog-to-digital converter) interrupt service routine (ISR) off the driver's MCPWM mid-PWM interrupt, so the chain driver.initcurrentSense.linkDrivercurrentSense.initmotor.linkCurrentSense has to run in exactly that order, all before motor.init. Skip or reorder any step and characteriseMotor silently returns zero.

The sketch ran. characteriseMotor passed. initFOC succeeded. MOT: PP check: OK!. The zero electric angle landed at 5.02 rad. The motor still refused to spin under load.

The d-axis lock signature was textbook. From the bench: "At T0, it doesn't resist. At T3 or T4, it gets stiffer. There are two stop points where it gets stable, about 90° apart." Two stable wells per electrical revolution is exactly what you get when the controller drives voltage along the rotor's d-axis instead of the q-axis: something was feeding it the wrong number for where the rotor was. After two days of bugs that hadn't been that, the question I should have been asking became unavoidable.

§05

The bisection

"I have characterised the motor very deeply. I have all the characteristics of the motor. This could be something else, like wires that are switched off."

Wires that are switched off. Or a sensor that is lying. Or anything else that is not in the firmware. What does the FOC chain look like if the sensor is replaced with a perfect one?

Back when I fixed radios, the move for a dead receiver was signal injection. You take a signal generator and feed a known tone into the audio pipeline partway down, at the volume pot, say, then at the amplifier input, and listen for it at the speaker. If the tone comes through clean, everything downstream of where you injected it is fine and the fault is upstream. Replacing the real, unknown signal with a perfect, known one turns a vague "no sound" into a clean bisection. The sensor is the front end of the FOC chain. A synthetic angle is the injected tone.

A #define and six lines of code:

synthetic-sensor.inocpp
#define USE_SYNTHETIC_SENSOR 1

#if USE_SYNTHETIC_SENSOR
float readSyntheticAngle() {
  float t = micros() * 1e-6f;
  return _normalizeAngle(SYNTH_RAD_PER_SEC * t);
}
GenericSensor sensor = GenericSensor(readSyntheticAngle);
#else
MagneticSensorI2C sensor = MagneticSensorI2C(AS5600_I2C);
#endif

// In setup(): alignSensor short-circuits when both are already set.
motor.sensor_direction    = Direction::CW;
motor.zero_electric_angle = 0.0f;

The synthetic sensor returns an angle ramping linearly from micros(): no noise, no nonlinearity, no flat patches where it forgets to update, no eccentricity. A perfect rotor angle reading. The two motor assignments short-circuit alignSensor, since there is no physical rotor to align to.

Reflash. Hit T3. The rotor spun, immediately and continuously, under the perfect sensor.

That was the bisection landing. The injected tone had come through clean: the FOC math, the Park and Clarke transforms, the space-vector PWM (SVPWM), the EG2133, the wiring to the motor were all correct. The failure was upstream of sensor.getAngle(). One flash had proved the entire chain good and pinned the fault on a magnet-mount problem I'd been treating as low-priority. The two days of bugs hadn't been wasted, but they'd been the wrong kind of work. The actual blocker had been sitting on the rotor shaft the whole time.

§06

The S sweep finds the lie

The synthetic-sensor test had told me the fault was in the real sensor, but not how bad it was or where. So I built a way to measure it against a truth I already trusted: the open-loop commanded angle. In open loop the firmware decides the rotor angle, ramping shaft_angle from the commanded velocity, and at low enough speed the rotor can't slip behind the field. Spin open-loop slowly, log what the AS5600 reports against what the firmware commanded, and the difference is the sensor's error across a full mechanical revolution.

I wired that up as a one-shot S command: drive the rotor open-loop at 0.3 rad/s with 6 V applied (too slow to slip), sample the sensor at 50 Hz against the commanded angle. One revolution takes about 21 seconds and yields 1048 samples, and the firmware computes a pass/fail verdict from three thresholds: max sensor deviation, the spread in the AS5600's field-magnitude register (MAG), and the spread in its automatic gain control (AGC). Switching USE_SYNTHETIC_SENSOR back to 0 and running it gave the first hard data on what the AS5600 was actually doing.

The verdict was UNHEALTHY ✗. The numbers:

SCOPE — sensor deviation, before the rebuildsweep-01 · 2026-05-29 11:14
0.00.51.01.52.02.53.03.54.04.55.05.56.0θ commanded (rad)−1.0−0.50.00.51.01.5dev (rad)

max|dev_rad| = 1.5 rad: the sensor reporting an angle about 86 degrees away from where the rotor actually was. The plateaus around −0.9 rad are stretches where the sensor froze for hundreds of milliseconds while the rotor kept turning; the spike up to +1.5 rad is it catching up in one jump. Root-mean-square (RMS) deviation: 0.74 rad.

The other two numbers were equally damning. The AGC drifted between 78 and 128 (a healthy sensor at the right magnet distance keeps it pinned), and the MAG register, the AS5600's internal field-strength reading, swung 3.5× across one revolution, where a healthy mount stays under 2×.

A static AGC reading of 128 had been the lead hypothesis a day earlier: I'd shimmed the magnet closer, watched the static reading climb from 15 to 128, and marked the problem solved. Statically, it was. Dynamically, the sensor was useless, because the magnet was eccentric on the shaft and swept the field across the AS5600's working range and out the other side every revolution. Static AGC of 128 was necessary but not sufficient.

No firmware was going to fix this. The 3.5× MAG swing was the smoking gun of mount eccentricity; the AGC drift was the consequence.

§07

One screwdriver, twelve times tighter

The pivot to physical took less than one sentence:

"Just let's commit this. I will replace the magnetic holder with a new setup, and then we're going to rerun the tests."

The original mount was a small disc of double-sided tape with the magnet pressed onto it. Tape is hard to align with the rotation axis: no edge to reference, the magnet sits wherever the tape is sticky, and any press-on pressure biases the result. By eye it had looked centred. The S sweep said otherwise.

The new mount took about half an hour to design, print, and fit: a 3D-printed disc with a centred bore for the magnet and a flat shoulder that registers against the rotor's existing geometry.

Same firmware. Re-ran S immediately after the rebuild, at 12:00:01, 46 minutes after the broken sweep at 11:14.

SCOPE — sensor deviation, after the rebuildsweep-02 · 2026-05-29 12:00
0.00.51.01.52.02.53.03.54.04.55.05.56.0θ commanded (rad)−1.0−0.50.00.51.01.5dev (rad)

max|dev_rad| = 0.12 rad. Twelve times tighter. The RMS deviation dropped from 0.74 to 0.05 (14× tighter). The AGC stopped drifting and pinned at 128 for every one of the 1048 samples. The MAG ratio dropped from 3.5× to about 1.4×. The verdict line printed HEALTHY ✓.

Then T2 in closed-loop velocity mode, P=0.1, I=0.5, the docs-canonical seeds. Continuous smooth rotation.

"It does rotate! It's spinning. \o/"

§08

The carburetor lesson

The S sweep proves the sensor is healthy enough for closed-loop FOC to work at all, but not that it's aligned optimally. After the rebuild the motor was smooth at low speeds but a little weak under load, and nudging the holder by a hair changed the high-velocity tracking. There was no in-firmware verdict for that.

The right model is older than FOC. On a carburetor car you set ignition timing by rotating the distributor body, and the field procedure isn't to measure with a meter: start the engine, hold a light-load speed, and turn the distributor until it sounds smoothest and pulls hardest. The maximum is the answer. A closed-loop bring-up is the same operation: the final magnet position is not a measurement but an adjustment under load, locked where rotation is smoothest and torque pulls hardest. (A bench tool measuring shaft speed externally would make that deterministic. Another adventure.)

The post-mortem fits on the back of an envelope. Every software bug was real and every fix is in the production code, but two long bench days of theorising were defeated by a piece of double-sided tape that wasn't on-axis. The codebase is now about half the size of peak v1, the rotor turns under closed-loop velocity at any reasonable target, and the next entry finally gets to be about position control instead of about whether the controller knows where the rotor is.

End of file · 002

References & Links

  1. SimpleFOC — Arduino field-oriented control librarydocs.simplefoc.com
  2. SimpleFOC — motor characterisation (characteriseMotor)docs.simplefoc.com
  3. SimpleFOC — velocity loop tuning guidedocs.simplefoc.com
  4. AS5600 12-bit magnetic position sensor — datasheetams-osram.com
  5. Distributor (ignition system) — the rotating part you turn to set timingwikipedia