Back to Projects

BLDC Robot Actuator

Moteus r4.11AS5047PCAN FDPythonFOCRoboticsView Source
BLDC Robot Actuator
ID: BLDC-ACTUATOR

At a Glance

GoalConvert a $30 hoverboard hub motor into a high-torque, closed-loop robotic actuator
Hardware400W BLDC motor, mjbots moteus r4.11, AS5047P absolute encoder, Arduino thermal monitor, 35V bench supply
What I BuiltA custom locked-rotor test fixture with wiring harness and sensor stack, plus a multi-sensor async Python telemetry pipeline over CAN-FD and a real-time haptic feedback detent controller
Key ResultMapped full motor parameters (30 pole pairs, 0.265 Ohm, 24.18 KV) and diagnosed a permanent 20% torque loss caused by NdFeB magnet demagnetization under high stall current

The Core Idea

Hoverboard motors are one of the best-kept secrets in robotics. For roughly $20-40, you get a large-diameter BLDC motor with an impressive torque output, delivering a torque-to-price ratio that leaves conventional servo motors in the dust. My goal was simple: turn these commodity motors into viable, closed-loop robotic actuators. This writeup covers the full stack, from winding physics to async Python architecture.

Here is how I did it.

Choosing the Right Motor Controller

The first challenge was finding a motor controller capable of running Field-Oriented Control (FOC) on a large, multi-pole hoverboard motor. After evaluating several options, I landed on the Moteus r4.11: a compact, high-performance brushless motor controller that natively supports CAN FD communication and has excellent Python tooling for scripting and data collection.

For position sensing and commutation, I chose the AS5047P absolute magnetic encoder. Unlike incremental encoders, it provides an absolute position reading on startup, which is critical for reliable homing and multi-turn tracking in a robotic joint.


The Hardware Stack

I fabricated a custom locked-rotor test fixture and wired the full sensor stack, including motor phase leads, encoder SPI lines, thermistor cabling, and CAN-FD termination.

ComponentSpecs
Hoverboard hub motor400W, 36V, BLDC, 30 pole pairs
mjbots moteus r4.11CAN-FD FOC controller, 54V/100A peak
AS5047P magnetic encoder14-bit resolution, SPI, 200Hz observer
Arduino + NTC thermistorExternal motor winding temperature
35V regulated bench supplyStiff bus voltage for repeatable tests

Motor Physics: What the Calibration Reveals

Before writing a single line of test code, you need to understand what you are driving. I ran the moteus automated calibration pipeline, which performs a sequence of controlled electrical measurements to fully characterize the motor from first principles. No datasheet required.

How Each Parameter is Measured

Winding Resistance (0.265 Ohm): The calibrator locks the rotor stationary and injects a known DC voltage across two motor phases, then measures the resulting steady-state current. With back-EMF eliminated because the rotor is not spinning, R = V / I gives direct phase resistance. This value is critical for computing the maximum thermally-sustainable current and predicting copper losses under load.

Phase Inductance (387 uH): The calibrator sweeps a small AC current signal across the winding at known frequencies and measures the phase lag between voltage and current. The reactance X_L = 2*pi*f*L gives inductance directly. This value determines the current loop bandwidth. The moteus auto-tunes its current loop PI gains directly from this measurement:

pid_dq_kp: 0.487       # proportional gain
pid_dq_ki: 333.07      # integral gain
torque_bandwidth: 200 Hz

KV (24.18 RPM/V): With the motor spinning freely at a known speed driven open-loop, the calibrator reads the back-EMF amplitude on the undriven phase. Since BEMF = KV * omega, dividing out the speed gives the torque constant Kt = 1/KV, which is the fundamental ratio between current and torque output.

Pole Pair Mapping (30 pairs, 64-entry offset table): The calibrator slowly rotates the rotor through one full mechanical revolution while commanding known electrical angles and recording the encoder reading at each step. This maps every electrical cycle to physical rotor position and detects non-uniformities in magnet spacing or flux. The resulting 64-entry offset table is applied in real-time at every commutation step, compensating for manufacturing tolerances that would otherwise produce torque ripple at the pole-pair frequency.

Final calibration results:

ParameterMeasured Value
Pole pairs30
Winding resistance0.265 Ohm
Phase inductance387 uH
KV24.18 RPM/V
Current loop bandwidth200 Hz
Current quality factor74.69%

The 30-pole-pair count is significant. Hoverboard hub motors use a high pole count to produce high torque at low RPM without a gearbox, running direct drive. This means electrical commutation happens 30x per mechanical revolution, which demands a fast control loop. The moteus handles this with a 32kHz PWM switching frequency and a dedicated Cortex-M4 + FPGA signal chain.


CAN-FD Communication

The moteus controller communicates over CAN-FD (Flexible Data-Rate CAN), not standard CAN. CAN-FD allows data payloads up to 64 bytes versus 8 bytes on classic CAN, and bit rates up to 8 Mbit/s on the data phase. For real-time motor control this matters because you can fit a full position, velocity, torque, and telemetry command-response pair in a single frame without fragmentation.

The moteus protocol uses a compact binary register map. Each register has a known address:

REG_POSITION  = 0x001
REG_VELOCITY  = 0x002
REG_TORQUE    = 0x003
REG_Q_CURRENT = 0x005
REG_FAULT     = 0x00b
REG_VOLTAGE   = 0x00d
REG_TEMP      = 0x00e   # Controller board temperature

Commands and queries are transactional. A single set_position() call sends a command frame and receives a reply containing the requested telemetry in the same round-trip. My control loop achieves 50Hz closed-loop updates over this interface using the moteus Python SDK, running inside a Python asyncio event loop.


The AS5047P Magnetic Encoder

Position feedback is provided by an AS5047P absolute magnetic rotary encoder. It is a 14-bit (16,384 counts/rev) Hall-effect sensor that reads from a diametrically magnetized magnet on the rotor shaft over SPI. Unlike optical encoders, it has no moving parts and is immune to contamination, making it well suited for motor applications.

The moteus runs a dedicated encoder observer tuned to 200Hz bandwidth:

encoder_filter_kp: 1013.4
encoder_filter_ki: 256753.5

This observer fuses raw encoder counts with the motor dynamic model to produce a smooth, low-latency velocity estimate even at high pole counts. This is critical for stable current control at 30 pole pairs, since phase lag in the velocity estimate would corrupt the FOC commutation angle and produce instability.

The calibration process maps encoder position to electrical angle across all 30 pole pairs, producing a lookup table that compensates for magnet placement tolerances and encoder eccentricity.


Locked-Rotor Torque Test

The core test is a locked-rotor (stall) torque characterization. The motor is mechanically clamped, and torque is commanded via a linear ramp from 0 to 15 Nm over 10 seconds, then held at maximum for 2 seconds. The key control configuration is pure feedforward torque mode:

await self.c.set_position(
    position=math.nan,    # No position target
    velocity=0.0,
    kp_scale=0.0,         # PID disabled
    kd_scale=0.0,
    feedforward_torque=cmd_torque,
    maximum_torque=MAX_TEST_TORQUE + 1.0,
    query=True
)

Setting kp_scale=0 and kd_scale=0 bypasses the position PID entirely. The controller becomes a pure current amplifier. Commanded torque maps directly to q-axis current via Iq = T / Kt, giving a direct measurement of the motor current-to-torque conversion.

Multi-Layer Safety Interlock System

Three independent abort conditions protect hardware during high-current stall testing:

1. Slip guard:       abort if |velocity| > 1.0 rev/s   (rotor not locked)
2. Controller temp:  abort if moteus board > 60 C       (register 0x00e)
3. Motor temp:       abort if winding sensor > 60 C     (Arduino serial)

If any condition triggers, set_stop() is called immediately and the partial dataset is flushed to disk.


Multi-Sensor Fusion: Arduino + moteus Telemetry

The moteus reports its own controller board temperature, but that tells you nothing about motor winding health. The windings are what actually fails first under sustained stall current. Winding temperature is a direct proxy for insulation life and magnet integrity.

I added an Arduino with an NTC thermistor mounted to the motor casing and fused both temperature channels into the telemetry stream. The challenge was that the main control loop runs async at 50Hz, so blocking serial reads would stall the entire loop. The solution is a background reader thread using a producer-consumer pattern:

class ArduinoTempReader:
    def __init__(self, port, baud):
        self.latest_temp = None
        self._stop = threading.Event()
        self._ser = serial.Serial(port, baud, timeout=1)
        self._thread = threading.Thread(target=self._read_loop, daemon=True)
        self._thread.start()

    def _read_loop(self):
        while not self._stop.is_set():
            raw = self._ser.readline().decode('utf-8', errors='replace').strip()
            value_str = raw.split(':')[-1].strip().split()[0]
            self.latest_temp = float(value_str)   # always fresh, non-blocking

The async control loop samples arduino.latest_temp each cycle with zero latency added to the critical path. The live console output shows both thermal channels simultaneously:

[HOLD] Cmd: 15.00 Nm | Meas: 6.86 Nm | Vel: 0.00 r/s | V: 35.00V | Ctrl: 43.0C | Motor: 40.0C

Both channels are timestamped and written to CSV at 50Hz for post-hoc thermal analysis.


Speed Sweep and Torque-Speed Curve

speed_test.py sweeps the motor from 1 to 40 rev/s in 2 rev/s increments, holding each step for 5 seconds while logging velocity tracking error, torque, and bus voltage. An automatic saturation detector aborts the sweep when the motor can no longer follow the commanded velocity:

if step_data['max_velocity_achieved'] < velocity * 0.5 and velocity > 5.0:
    print("Physical limit reached. Aborting sweep.")
    break

With KV = 24.18 RPM/V and a 35V bus, theoretical no-load max speed is approximately 846 RPM (14.1 rev/s). The sweep quantifies how much the real speed falls short of this ideal due to resistance losses, frictional loading, and controller current limits.


Haptic Feedback Mode

As a separate application on the same hardware stack, I implemented a haptic detent knob. The motor simulates the feel of a precision mechanical encoder at 100Hz. The control law runs a real-time virtual spring, damper, and friction model:

spring_torque   = STIFFNESS * position_error        # restoring force to detent
damping_torque  = -DAMPING * current_vel            # velocity-proportional drag
friction_torque = -copysign(FRICTION, current_vel)  # coulomb friction
total_torque    = spring_torque + damping_torque + friction_torque

This demonstrates closing a real-time feedback loop at the torque level, which is the same technique used in force-controlled exoskeletons, haptic surgical tools, and collaborative robot arms.


Data Pipeline and Root Cause Analysis

All test runs auto-save timestamped CSVs. plot_results.py generates matplotlib figures with dual y-axes, estimated current overlays using the measured Kt, and peak annotations.

During testing, I discovered a revealing anomaly. The first locked-rotor run measured 8.59 Nm, but every subsequent run stabilized at 6.84 Nm, a permanent 20% reduction. I systematically eliminated causes:

  • Supply voltage: Rock-solid at 35.00V across all runs. Ruled out.
  • Velocity creep (back-EMF): Velocity read 0.00 r/s in later runs. Ruled out.
  • Thermal winding resistance rise: This would cause gradual drift, not a one-time step change.

The data pointed to partial permanent magnet demagnetization. The second test pushed motor surface temperature to 64C, and internal winding temperature likely reached 80 to 100C. At those temperatures, the coercivity of NdFeB magnets can drop below the demagnetizing field generated by high stall current, causing irreversible flux loss. The result is a permanently reduced torque constant Kt, consistent with the one-time step decrease in measured torque that did not recover with cooling.


What I Would Do Differently

The demagnetization event was avoidable. In future tests I would impose a hard winding temperature abort at 55C rather than 60C, leaving a wider margin before the magnet flux loss threshold. I would also add a direct Kt measurement at the start of every test run by computing torque per amp from the telemetry data, so any degradation is caught and logged automatically rather than appearing as an unexplained anomaly in post-processing.


Skills Demonstrated

AreaWhat Was Done
Motor ControlFOC feedforward torque mode, back-EMF analysis, Kt measurement, demagnetization diagnosis
Embedded SystemsCAN-FD protocol, SPI encoder integration, AS5047P observer tuning, Arduino serial peripherals
Software ArchitecturePython asyncio + threading, producer-consumer pattern, 50Hz real-time telemetry loop
Signal ProcessingEncoder observer filter design (Kp=1013, Ki=256K), velocity estimation from 14-bit position
Test EngineeringMulti-layer safety interlocks, automated timestamped logging, multi-run repeatability analysis
Root Cause AnalysisData-driven failure hypothesis from telemetry signatures