Test Types & Methods

Ramp Motor Throttle Safely on a Test Bench

Learn how to ramp brushless motor throttle on a test bench with DShot from a microcontroller, abort limits and a teardown that always stops the motors.

JJulien Buteau
intermediate10 min readOctober 27, 2026

A brushless motor test that jumps from one throttle level to the next produces current spikes, bus overvoltage on deceleration and readings that depend on timing rather than on the motor. Learn how to drive throttle as ramps from a microcontroller over DShot, record throttle, current and speed as one trace, abort on limits, and make sure the motors stop on every exit path of a TofuPilot procedure.

Why Ramps and Not Steps

A step in throttle asks the motor to change speed instantly. The ESC answers with whatever current the motor needs to accelerate its rotor and propeller, and on the way down it brakes the motor and pushes the recovered energy back into the bus.

Example values measured on a 3115-size, 1250 KV motor with a 4-in-1 BLHeli_32 ESC on a 6S bus, without propeller:

ProfileObserved effect
Steady 70 % throttle2.65 A, 22,600 rpm
Throttle stepsCurrent spikes up to about 10 A per motor
Step down from high throttleBus raised to 36.6 V on a 6S nominal (22.2 V) bus, powered by a switching bench supply

The spikes are almost four times the steady current at 70 %, without a propeller. The overvoltage is above the 25.2 V of a fully charged 6S pack, and a switching bench supply cannot sink the returned current, so nothing holds the bus down.

Flight controller motor test commands make this worse. ArduPilot's MAV_CMD_DO_MOTOR_TEST sets a throttle and a duration, and the throttle cannot be changed while a test is running. A stepped profile therefore becomes a sequence of stop and restart, each one a full acceleration from rest. Driving DShot directly from a microcontroller removes the flight controller from the loop and allows a true ramp.

Bench Architecture

Split the work so that each part does what it is good at:

PartResponsibility
Station PC with the TofuPilot CLISequences the test, sends ramp targets, records measurements, applies limits
Microcontroller on USB serialGenerates DShot300 continuously, executes ramps with deterministic timing, runs a watchdog, reports applied throttle and eRPM
4-in-1 ESCDrives the motors, returns eRPM (bidirectional DShot) or telemetry frames
INA228 on the ESC DC inputMeasures current
Hardwired emergency stopRemoves ESC power, independently of all software

The ramp itself runs on the microcontroller. A Python loop on a desktop operating system cannot guarantee a frame every millisecond; a timer interrupt can. The host only sends targets and rates.

DShot in Brief

A DShot frame is 16 bits: an 11-bit value, one telemetry request bit and a 4-bit checksum. Values 0 to 2047 are split into 0 (motor stopped), 1 to 47 (commands) and 48 to 2047 (throttle). DShot300 sends 300 kbit/s, so one frame takes about 53 µs. The ESC expects frames continuously and arms after receiving value 0 for a short time, so the firmware sends 0 whenever no motor is commanded.

Throttle in percent maps to the DShot value as 48 + round(pct / 100 x 1999).

Step 1: Define the Firmware Contract

The microcontroller firmware is yours to write. What matters for the test is a small, explicit line protocol over USB serial. This guide uses the following one as an example:

CommandReplyEffect
RAMP <motor> <target_pct> <rate_pct_s>OKRamps one motor from its current throttle to the target at the given rate
STOP <rate_pct_s>OKRamps every motor to 0 at the given rate
STATUS <motor>OK <throttle_pct> <erpm>Throttle currently applied and last eRPM received

Two behaviours are required in the firmware, not optional:

  • Watchdog. If no command arrives for 500 ms, the firmware ramps every motor to 0. A crashed host, a killed CLI or an unplugged USB cable then ends in a stop, not a motor holding 70 %.
  • Rate limit. The firmware refuses a rate above a fixed maximum, so a typo in a procedure cannot turn a ramp back into a step.

Step 2: Write the Motor Plug

The plug wraps the serial protocol, converts eRPM to mechanical rpm and stops the motors when it is destroyed:

requirements.txt
pyserial>=3.5smbus2>=0.4
plugs/motor.py
39 lines
import timeimport serialclass MotorDriver:    def __init__(self, port, pole_pairs, baudrate=115200):        self.pole_pairs = pole_pairs        self.ser = serial.Serial(port, baudrate, timeout=0.2)        time.sleep(2.0)  # many boards reset when the port opens        self.ser.reset_input_buffer()        self._cmd("STOP 50")    def __del__(self):        if hasattr(self, "ser") and self.ser.is_open:            try:                self._cmd("STOP 50")            finally:                self.ser.close()    def _cmd(self, line):        self.ser.write((line + "").encode())        reply = self.ser.readline().decode().strip()        if not reply.startswith("OK"):            raise RuntimeError(f"{line!r}: unexpected reply {reply!r}")        return reply[2:].strip()    def ramp_to(self, motor, target_pct, rate_pct_s):        self._cmd(f"RAMP {motor} {target_pct} {rate_pct_s}")    def stop(self, rate_pct_s):        self._cmd(f"STOP {rate_pct_s}")    def status(self, motor):        """Return [throttle_pct, rpm] as applied by the firmware."""        throttle, erpm = self._cmd(f"STATUS {motor}").split()        # eRPM counts electrical revolutions: divide by the pole pairs        return [float(throttle), int(erpm) / self.pole_pairs]

A 12N14P motor has 14 magnets, so 7 pole pairs: 158,200 eRPM is 22,600 rpm. Each STATUS poll also counts as a command for the watchdog, so a phase that records data keeps the firmware alive.

Step 3: Record the Ramp as One Trace

Throttle, current and speed share a time axis. Record them as one multi-dimensional measurement with three y axes, and put the pass limits on aggregations:

procedure.yaml
72 lines
name: Motor Ramp Testversion: 0.1.0execution:  workers: 1plugs:  - name: Motor Driver    key: motor    python: plugs.motor:MotorDriver    scope: station    with:      port: /dev/ttyACM0      pole_pairs: 7  - name: Current Monitor    key: current_monitor    python: plugs.ina228:INA228    scope: station    with:      bus: 1      address: 0x40      shunt_ohm: 0.0005setup:  - name: Motors Stopped    python: phases.ramp:stop_allmain:  - name: Ramp Motor 1 to 70 %    key: ramp_m1    python: phases.ramp:ramp_profile    timeout: 60s    with:      motor_index: 1      target_pct: 70      rate_pct_s: 20      hold_s: 3.0      abort_current_a: 6.0      min_rpm_per_pct: 200    measurements:      - name: Ramp Motor 1        key: ramp        description: 0 to 70 % at 20 %/s, 3 s hold, ramp down at 20 %/s. No propeller.        x_axis:          legend: Time          unit: s        y_axis:          - legend: Throttle            key: throttle            unit: "%"          - legend: Current            key: current            unit: A            aggregations:              - type: max                validators:                  - operator: "<="                    expected_value: 4.0          - legend: Speed            key: rpm            unit: rpm            aggregations:              - type: max                validators:                  - operator: ">="                    expected_value: 21000                  - operator: "<="                    expected_value: 24500teardown:  - name: Stop All Motors    python: phases.ramp:stop_all

workers: 1 keeps the phases sequential. TofuPilot runs independent phases in parallel by default, which on a motor bench means two phases commanding the same ESC.

The phase separates two kinds of limits. Abort limits protect the bench and are checked inside the loop: the phase stops the motor and fails at once. Pass limits judge the motor and are evaluated on the recorded trace by the validators.

phases/ramp.py
53 lines
import timedef ramp_profile(motor, current_monitor, measurements, phase, log,                 motor_index, target_pct, rate_pct_s, hold_s, abort_current_a, min_rpm_per_pct):    times, throttle, current, rpm = [], [], [], []    t0 = time.monotonic()    def save():        m = measurements.ramp        m.x_axis = times        m.y_axis.throttle = throttle        m.y_axis.current = current        m.y_axis.rpm = rpm        m.y_axis.current.aggregations.max = max(current, default=0.0)        m.y_axis.rpm.aggregations.max = max(rpm, default=0.0)    def abort(reason):        motor.stop(rate_pct_s)        save()        phase.fail(reason)    motor.ramp_to(motor_index, target_pct, rate_pct_s)    ramp_down_at = target_pct / rate_pct_s + hold_s    ramping_down = False    while True:        elapsed = time.monotonic() - t0        if not ramping_down and elapsed >= ramp_down_at:            motor.ramp_to(motor_index, 0, rate_pct_s)            ramping_down = True        thr, speed = motor.status(motor_index)        amps = current_monitor.read_current()        times.append(round(elapsed, 3))        throttle.append(thr)        current.append(amps)        rpm.append(speed)        if amps > abort_current_a:            abort(f"{amps:.2f} A above abort limit {abort_current_a} A at {thr:.0f} %")        if not ramping_down and thr > 10 and speed < thr * min_rpm_per_pct:            abort(f"{speed:.0f} rpm at {thr:.0f} % throttle: stalled or desynchronized")        if ramping_down and thr == 0:            break        time.sleep(0.02)    save()    log.info(f"Peak {max(current):.2f} A, top speed {max(rpm):.0f} rpm")def stop_all(motor):    motor.stop(50)

The stall check uses the ratio observed on the example motor: 22,600 rpm at 70 % is 323 rpm per percent, so 200 rpm per percent leaves room for spin-up lag while catching a motor that has lost synchronization. save() runs before phase.fail() because phase.fail() exits the function immediately; without it the trace that explains the abort would be lost.

On the dashboard, the trace shows where the current rose relative to the throttle, and the control chart of the max-current aggregation shows drift across units.

Step 4: Stop the Motors on Every Exit Path

List every way a test can end and check what stops the motor in each case:

Exit pathWhat stops the motor
Phase passesThe phase ramps down itself
Abort limit or phase.fail()The abort ramp, then the teardown phase
Phase timeoutThe teardown phase (teardown always runs)
Operator stops the runThe teardown phase
Python exception in the phaseThe teardown phase
Plug destroyed (end of station, new deployment)MotorDriver.__del__
CLI killed, host crash, USB unpluggedFirmware watchdog after 500 ms
Runaway, fire, mechanical failureHardwired emergency stop

The last line is the only one that is a safety function. Teardown phases, plug destructors and firmware watchdogs are software and fail with the software. A bench with rotating parts needs a hardwired emergency stop that removes power (ISO 13850, stop category 0 or 1), a guard around the rotating parts (ISO 14120) with an interlock where it can be opened (ISO 14119), and a risk assessment per ISO 12100 under the Machinery Directive 2006/42/EC, replaced by the Machinery Regulation (EU) 2023/1230 from 20 January 2027. Operating under 60 V DC limits shock risk, not the energy, fire or projectile risk.

Ramp down in teardown, at a bounded rate. A teardown that writes throttle 0 instantly turns the stop into the largest deceleration step of the test, with the regenerative overvoltage that comes with it.

Without Propeller and With Propeller

The same procedure structure serves both cases. The numbers, the hardware around it and the risks change:

Without propellerWith propeller
Current per motor0.1 A idle to 3.5 to 4.5 A at 100 % (measured)Tens of amps, 60 to 80 A at full throttle on 6S (estimate for a typical propeller)
Power for four motors at full throttleUnder 500 W22.2 V x 80 A x 4 = 7.1 kW (estimate)
Power sourceBench supply, with a battery in parallel or a clamp for regenBattery pack, with pre-charge or anti-spark connection
Ramp rate20 to 50 %/sSlower, sized from the measured current overshoot
MechanicalMotor mount onlyThrust stand rated for the thrust, guard against blade release
What the test provesElectrical and bearing health, ESC channelThrust, efficiency, thermal behaviour under load

Keep the two as separate procedures, or as separate phases with their own with: values and limits. A limit tuned without propeller fails every unit with one, and an abort current sized for a propeller does not protect a bare motor.

Common Pitfalls

Running the ramp loop in Python. Operating system jitter makes the ramp uneven and the timing unrepeatable. Ramp on the microcontroller; send targets from the host.

Using the flight controller motor test for profiles. MAV_CMD_DO_MOTOR_TEST cannot change throttle mid-test, so profiles become stop and restart sequences with a spike at each restart.

No firmware watchdog. A killed CLI leaves the last throttle applied. The watchdog is the stop for every failure the host cannot handle.

Writing throttle 0 in teardown. An instant stop is a step down, with the overvoltage of regenerative braking. Ramp down.

Calling phase.fail() before saving the measurement. The function exits and the trace is lost. Save first.

Treating teardown as the emergency stop. It is software. The emergency stop is a hardwired circuit that removes power.

Leaving phases in parallel. Set workers: 1 on a motor bench.

Key Points

  • Throttle steps produce current spikes and regenerative overvoltage; ramps avoid both and make readings repeatable.
  • Generate DShot and execute ramps on a microcontroller; the TofuPilot plug sends targets and reads back throttle and eRPM.
  • Divide eRPM by the pole pairs (7 for a 12N14P motor) to get mechanical rpm.
  • Record throttle, current and rpm as one multi-dimensional measurement; check abort limits in the loop and pass limits on aggregations.
  • Teardown, plug destructor and firmware watchdog stop the motor in software; only a hardwired emergency stop is a safety function.

For the wider picture of motor and actuator test benches, see Motor and Actuator Testing with TofuPilot.

More Guides

Put this guide into practice