← BlogBlog

A digital twin of the bench, and what it found in the FOC drive

The simulator has run the real drive code since the early milestones. The firmware’s Engine steps against a simulated board, and host tools cannot tell it from hardware. The model under it was simple, though: a linear dq motor, an inverter that averaged each PWM period, and ideal sensors. That was enough to develop control loops, but it could not reproduce most of the problems that only showed up on the bench.

Over the last two sessions it became a digital twin of one specific setup: bench 2 (NUCLEO-F302R8 with the X-NUCLEO-IHM07M1 L6230 shield) driving motor 3 (maxon EC-i 40) from 18 V. This post covers how it is built, how closely it matches the bench, and what it found in the FOC drive along the way. Most of the useful results came from places where the twin and the bench disagreed.

What the twin is

A simulation counts as a twin when it is calibrated to one physical unit and can run beside it and be compared. The new mmc-twin crate has four layers:

Layer What it models
Plant One phase-domain model of the machine, the switching bridge and the DC link. Each leg is switched, conducting through a body diode, or open, decided by the gates and the current. Dead time, a floating six-step phase, coasting and regeneration all come out of the same equations. It adds saliency, flux harmonics, cogging, Coulomb and viscous friction, a thermal node with R(T) and ψ(T), and a bench supply that cannot sink current.
Board The F302’s centre-aligned PWM with its compare quantisation and one-period duty latency. Currents are sampled at the counter peak, through the shunt only while the low side conducts. It also has the terminal dividers clipped at ADC full scale, a 12-bit ADC with noise, and halls with offset and hysteresis.
Rig The firmware’s own Shared and Engine on that board, unchanged, recording telemetry in the same CSV format as a hardware capture.
Preset Motor 3, the L6230, the bench supply and the F302 board, with every number taken from a datasheet, the profiler or the firmware constants.

There are three ways to use it from the host tool:

  • mmc-host serve --twin motor3-f302 serves the twin over TCP in real time. capture, profile, apply and the panel run against it unchanged.
  • mmc-host replay <capture.csv> reruns the commands from any hardware capture on the twin, and tools/twin_compare.py scores the pair.
  • mmc-host shadow drives the board and the twin together, frame by frame on the board’s clock, feeding the twin the measured bus voltage. It prints both live and writes both captures.

Plant parameters can be overridden from the command line for experiments, for example --twin "motor3-f302:l=0.42e-3,dead_time=0".

How close it is

Nothing was fitted to the captures it is scored against. Replaying the existing hardware captures for the last running second of each:

Experiment duty iq RMS current input power
Hall six-step, 120° and 180°, 150–800 rad/s el 2% 4% 6% 4%
Hall FOC, ±100–800 rad/s el 1% 4% 10% 11%
Sensorless FOC starts and steps — 5% — —

Transients also line up: the I-f ramp, the observer blend and handover timing, and speed ramps match frame by frame. The 180° six-step overcurrent trip from the last post also happens in the twin, at about 770 rad/s el against the bench’s 712. The twin also runs the real profiler stages. It reads the dead-time voltage as 398 mV, where the bench reads 382 mV.

The averages were close from the start, but the ripple was not. The first version was too smooth. Bench iq ripple in hall FOC was 3–5× the twin’s, and speed at 800 rad/s el had a standard deviation of 55 rad/s on the bench against 17 on the twin. Closing that gap meant finding what on the real motor caused the extra ripple. Each cause turned out to be something the firmware could also use.

How far the twin's ripple was from the bench

Twin replay vs hardware captures · motor 3 · 18 V
0% 20% 40% 60% 80% 27 13 10 hall FOC RMS current 70 41 15 hall FOC i_q ripple 76 49 22 hall FOC speed ripple 59 31 22 sensorless speed ripple mean |twin − bench| / bench, across the validation captures first build + hall placement, cogging + position torque

Mean relative error of the twin against the bench over each set of validation captures. Each step adds something measured on the real motor, as described below; none of it is fitted to these captures.

Finding 1: one hall sensor is 3.4° off

The largest single gap was a 2nd-order component in iq, twice per electrical revolution. On the bench it was 47 mA at 400 rad/s el; the twin had almost none. Timing the six hall sectors at steady speed showed why. Hall FOC assumed every sector is 60° wide, but motor 3’s sectors run from 56° to 63°: one sensor is 3.4° electrical out of place. Each time the rotor crosses a sector that is narrower or wider than assumed, the hall-interpolated angle jumps, and the current loop sees a step in angle error twice per revolution.

The twin’s hall model now has per-sensor placement and skew, fitted to the measured widths with a 0.02° residual, and it reproduces the ripple. The firmware fix was then developed on the twin first and moved to the bench afterwards.

New feature: hall sector width parameters (hall_w0 to hall_w5, firmware 16). The hall angle and hall speed use each sector’s measured width instead of 60°. tools/hall_widths.py measures them from a steady run, and they are saved with the motor’s profile.

Measured hall sectors, and the ripple they caused

Real captures (bench) and twin · motor 3 · hall FOC
50° 54° 58° 62° 66° 63.3 S0 60.0 S1 56.3 S2 62.9 S3 60.7 S4 56.9 S5 hall sector width (dashed: 60°) 0 40 80 120 160 400 rad/s 800 rad/s -400 rad/s 2nd-order i_q ripple (mA) bench, 60° bench, calibrated twin, 60° twin, calibrated

Left: sector widths from timing at steady speed. Right: amplitude of the twice-per-revolution component of iq, with the firmware assuming 60° sectors (faded) and using the measured widths (solid). The twin was given the same misplaced sensor and shows the same drop.

On the bench the 2nd-order ripple fell from 47.6 to 6.3 mA at 400 rad/s el, 156 to 47 mA at 800, and 53 to 10 mA at −400. The speed standard deviation at 400 rad/s el fell from 24.9 to 19.0 rad/s.

The same work turned up a related bug in the hall speed estimate. Between edges it is bounded by “the rotor cannot be faster than one sector in the time since the last edge”, and that bound used the previous sector’s width. A wide sector after a narrow one dipped the speed by about 10%. It now uses the current sector’s width.

Finding 2: low-speed hunting is a torque fixed to the rotor

At 100 rad/s el in hall FOC, the bench’s speed swung with a standard deviation of 44–53 rad/s, while the twin held within 4.5. The obvious suspect was a loop limit cycle, but the timing data ruled it out. Over 8 turns, 89% of the variation in the 42 hall-state durations of a turn (7 pole pairs × 6) repeated at the same rotor position. Sector times ran from 5 to 39 ms around a turn, and the rotor nearly stopped at one spot every turn.

tools/drag_profile.py turns a steady run into torque against position. Each hall state’s duration gives the mean speed over that slice of the turn. The change in kinetic energy between slices, with J from the datasheet, gives the net torque, and subtracting kt·iq leaves the external torque. Runs in both directions separate the two parts, because friction changes sign with direction and a position-dependent torque does not.

  • Friction: 7.40 mN·m, flat around the turn, the same at 100 and 200 rad/s el. The twin’s preset already had 7.2.
  • Position-dependent torque: mainly 12 cycles per turn, one per stator slot, about 6–7 mN·m in amplitude, with smaller components at 6, 9, 14, 15 and 18 or 24 per turn.

Since this torque is close to the friction, the rotor nearly stalls at the low points when it is turning slowly. With the measured torque in the twin, its speed spread at ±100 and ±200 rad/s el is 52 / 57 / 17 / 17 rad/s against the bench’s 44 / 53 / 17 / 17, and the two torque profiles correlate at 0.87.

The torque around one turn, bench and twin

Real captures (bench) and twin · motor 3 · hall FOC at ±100 and ±200 rad/s el
-8 -4 0 4 8 0° 60° 120° 180° 240° 300° 360° position-dependent torque (mN·m) vs mechanical angle bench, from drag_profile.py twin, same tool

Position-dependent part only, with friction removed. The halls have no index, so the twin's profile is rotated by the pole-pair shift that best matches the bench. Both are read through the same tool, which smooths each order by sinc²(πn/42) (0.76 at order 12); the tool corrects for this.

There were two limits on this. 42 samples per turn cannot tell an 18th-order component from a 24th. In the twin, 18 at the measured strength stalls the motor at 100 rad/s el, while 24 makes it hunt like the bench, so the preset uses 24. Near stall the twin still hunts about 10% harder than the bench. Separating those orders properly needs motor 3’s encoder, which gives 4096 counts per turn but is not wired to this shield yet.

As an independent check on the mechanics, a coast from 1000 rad/s el falls to 421 in 44 ms on the bench and to 403 in the twin.

The firmware does not use this yet. The next step is to feed the torque forward as an iq table over the turn, which should remove most of the hunting. The halls have no index, so the drive has to work out which of the 7 pole pairs it is on at start-up. Correlating the first turn’s sector times against the table should be enough.

Finding 3: an online R and ψ estimator, debugged on the twin

Winding resistance rises as the copper warms, and magnet flux falls as the magnet warms. Both feed the current-loop tuning and the sensorless observer, and so far both have been fixed values from the profiler. The aim was to estimate them while the motor runs.

New feature: online R/ψ estimation (mmc_core::estim). This is a no_std Kalman filter for R, ψ and a d-axis voltage offset, running on the host in shadow and mmc-host estimate, and in the firmware from version 17 (telemetry channels r_hat and psi_hat).

At speed, ψ·ω is far larger than R·iq, so R cannot be seen from the q axis. The drive injects a small negative id, which produces no torque but makes R visible on the d axis. Three things had to be right before the estimate meant anything. Each was found on the twin first, where the true values are known:

  1. R from the d axis only, ψ from the q axis with R̂·iq subtracted. Letting the q axis pull on R gives it every q-axis transient.
  2. A d-axis offset state, and R from steps in id. Dead-time compensation error and voltage-angle error put an offset on vd that a constant id cannot separate from R. Stepping id between two values can.
  3. Block means, not samples. Within the current loop’s bandwidth, id and vd move together with a slope that is not R. Fed raw 2 kHz samples, the filter read 0.7 Ω on the twin, where the true static slope was 1.19 Ω, and as low as −0.27 Ω on the bench. Fed 50 ms averages, it reads the static slope.

The same filter on raw samples and on block means

Real captures · motor 3 · hall FOC, id stepped −0.2 ↔ −0.6 A
-0.4 0 0.4 0.8 1.2 1.6 standstill R 1.418 Ω 1.32 1.22 200 1.36 0.65 600 1.38 0.22 800 1.37 0.02 600, 1 s 1.37 -0.27 800, 1 s 1.33 0.20 400→800 bench R̂ (Ω) at each hall FOC run (rad/s el) Kalman filter on raw 2 kHz samples on 50 ms block means

Mean R̂ over the last second of each run. On raw samples the estimate depends on speed and dither period and can go negative. On 50 ms means it reads 1.32–1.38 Ω on every run, against 1.40 Ω from Δvd/Δid at two steady id values.

New features: id injection and dither (id_inject, firmware 15; id_dither and id_dither_period, firmware 18). Hall FOC can hold a d-axis current, or square-wave it between two values, above the estimator’s 100 rad/s el speed floor. This lets the drive update R̂ on its own, with no host in the loop. Both are off by default.

Results:

  • Drift tracking on the twin, with the winding held 50 K hotter and only the firmware running: ΔR̂ +0.076 Ω against +0.083 Ω true, and Δψ̂ −5.92% against −6.00% true.
  • On the bench, the firmware’s estimate agrees with the host’s on the same frames: R̂ 1.286 / 1.316 Ω at 400 / 800 rad/s el against the host’s 1.277 / 1.316, and ψ̂ 6.318 / 6.325 mWb against 6.325 / 6.327.
  • Cost: the worst-case ISR went to 4700 of the 7200 cycles available.

The estimator sees R and ψ as referred to the commanded voltage, not the internal values. That is what the controller and observer need. It explains why ψ̂ reads 6.31 mWb against the profile’s 6.645. A scope on a coasting terminal measures ψ = 6.618 mWb directly, with 5th and 7th flux harmonics of 0.10% and 0.02%, so the 5% gap is a bias in the commanded voltage, not a flux error.

Finding 4: fixes to the profiler

The twin runs the real profiler, so the profiler’s own errors could be checked against a known answer.

The R/L probe’s inductance depends on the probe current. A winding is a first-order system, so its current step response should never overshoot. On the bench it did, and the overshoot fell as the align current rose. At the default 0.35 A, the soft park against the 12-per-turn detent lets each voltage step move the rotor, and the rotor’s back-EMF bends the exponential. In the twin the cause at low current is different: the current ripple crosses zero, where the dead-time voltage flips. Both read low, and both converge with more current.

Fitted inductance against probe current

Real captures (bench) and twin · motor 3 · R/L step probe
0.15 0.2 0.25 0.3 0.35 0.4 0.3 A 0.4 A 0.5 A 0.6 A 0.7 A 0.8 A 0.9 A 1 A profile 0.356 mH 4.7% overshoot 1.1% overshoot 0.4% overshoot 0.8% overshoot ✕ fit fails 62% overshoot fitted L (mH) vs probe align current bench twin

Each bench point is a repeat at the same voltages. Turning dead-time compensation off gives the same readings, so it is not involved on the bench. The 0.356 mH profile value was taken at about 0.85 A in an earlier 30 V session; the datasheet gives 0.34 mH.

tools/profile.py now warns when the probe current overshoots by more than 1%. On motor 3 the probe should run at 1.2 / 2.0 V.

New feature: the coast profiler stage. With the bridge off, two terminals above the sense divider’s floor differ by exactly the line back-EMF, and the hall edges over the coast give angle and speed. ψ is then a linear fit, with no scope and no load model needed: bench 6.592 and 6.559 mWb on two coasts, against 6.618 from the scope, and 6.657 on the twin for a true 6.645. It is opt-in (mmc-host profile --only coast) and needs halls. tools/profile.py now prefers it over the I-f sweep.

The coast also showed that the IHM07M1’s terminals do not settle at 0 V with every switch open. They settle at 1.15 V. The twin now models this float bias as well.

What did not work

  • Calibrating the twin to read like the bench through the profiler made it worse elsewhere. Raising L to 0.42 mH and switch resistance to 0.8 Ω matched the bench probe’s readings, but doubled the duty error and stopped the 180° trip. The bench probe’s low reading was the probe-current effect described above.
  • L from the dither. Reading inductance from the vq step that a step in id causes gave 0.85 mH for a true 0.356 on the twin. The dead-time error turns with the current vector and with the PWM latency phase, and both change with speed. Dropped.
  • Two claims made and withdrawn during the work: that bench hall FOC was voltage-limited at 590 rad/s (a ramp was still in progress inside the scoring window; both reach 806), and that the firmware’s hall angle lagged one tick (the diagnostic compared it against the plant one tick late; the angle is accurate to 0.006 rad). Neither reached the code.

Summary of new features

Feature Where What it does for the drive
Hall sector widths hall_w0–hall_w5, fw 16 Removes the angle step at uneven hall edges: 2nd-order iq ripple down 70–87% on motor 3
Hall speed bound fix hall speed estimate Removes a ~10% speed dip after a narrow sector
Online R/ψ estimator mmc_core::estim, r_hat / psi_hat, fw 17 Tracks winding temperature and flux while running
id injection and dither id_inject, id_dither, id_dither_period, fw 15 / 18 Makes R observable without a host
Coast flux stage mmc-host profile --only coast ψ from a coast in the drive’s own telemetry, no scope
Probe overshoot check tools/profile.py Rejects R/L fits disturbed by a soft park
Twin as a device, replay, shadow mmc-host serve --twin, replay, shadow Develops and checks firmware against a model of this exact bench, and compares the two live

The twin’s regression suite (cargo test -p mmc-twin, about 25 s) now covers the plant physics and reproductions of each bench result above. It includes the conduction harmonic current, the 180° trip, hall FOC duty and top speed, sensorless starts in both directions, low-speed hunting, and the estimator tracking a warmed winding.

Next

  • Feed the position torque forward in hall FOC, starting with the pole-pair search at start-up.
  • Wire motor 3’s encoder, to separate the 18th and 24th torque orders and give an angle reference for flux maps.
  • A second twin preset for motor 1 on bench 1 (the G474), which has the most complete profile. Motor 1 has no halls, so its coast stage would need angle from the terminals.
  • A temperature sensor on the winding, to calibrate R(T) and ψ(T) and check the estimator against a real temperature rise.

The repository has the tools and the captures behind every chart above. The twin itself now lives in a separate private repository; it runs in the browser on the virtual motor page.