
Introduction
ESC and Motor Overview
The Electronic Speed Controller (ESC) is the bridge between a drone's flight controller and its brushless motors. On every throttle update, the flight controller sends a digital or analog command (PWM, OneShot, DShot300/600/1200) that the ESC translates into three-phase commutation pulses driving the BLDC motor. A single drone arm contains three failure surfaces stacked back-to-back: the ESC FETs, the motor windings, and the prop mount. All three must be characterized as a unit, because a defect in any one corrupts the relationship between commanded throttle and delivered RPM.

The two halves of the chain under test: the 4-in-1 ESC's FET banks and the motor's stator windings.
Modern drone ESCs run BLHeli_32, BLHeli_S (8-bit), or the open-source AM32 firmware. BLHeli_32 was discontinued in 2024; AM32 is its actively maintained successor and flashes onto most BLHeli_32 hardware. These firmwares handle commutation timing via back-EMF zero-crossing sensing on the un-energized phase, and most now stream bidirectional DShot eRPM telemetry back to the flight controller. Production must verify the entire chain end-to-end, because a motor with the wrong KV stocked, a demagnetized rotor, or a misflashed ESC firmware all look identical at incoming inspection but produce wildly different in-flight behavior.
Test Purpose
ESC throttle-to-RPM characterization measures how a specific ESC + motor pair responds to a swept throttle command. The procedure produces a per-unit dataset:
- Throttle-to-RPM curve: RPM vs commanded throttle, fitted as a near-linear function above the deadband
- Deadband: the lowest throttle that produces any rotation (as a rule of thumb, 3-5% of full scale on healthy units)
- Step response time: time from a 0-to-100% throttle command to 95% of target RPM
- KV constant: RPM per volt, defined from unloaded motor speed at a known voltage; with a propeller installed the bench records a loaded max-RPM/V proxy instead, calibrated against a golden reference unit
- Current signature across the sweep

The two results that gate the unit: linearity above the deadband, and how fast the motor answers a full step.
Per-unit characterization eliminates entire classes of post-shipment failures: yaw drift from KV-mismatched motors on a quad, ESC desync from misflashed firmware, and reduced thrust ceiling from demagnetized rotors. Thermal behavior under sustained load is covered separately by the propeller and burn-in procedures. The procedure adds 60-120 seconds per arm at end-of-line.
Equipment & Setup
To implement ESC characterization on a production line, the following are required:
- A motor test bench with thrust + torque + RPM + electrical measurement, integrated with throttle signal generation
- A regulated DC power supply matched to pack voltage and peak current
- The Device Under Test (DUT): a complete ESC + motor + prop hub assembly mounted to the bench
- Firmware: ESC running production-target firmware (AM32, BLHeli_32, BLHeli_S), with bidirectional DShot enabled for eRPM telemetry
- A TofuPilot Framework procedure to sweep throttle, capture RPM, fit the curve, and validate per-unit metrics
- The TofuPilot Dashboard to log results, monitor 3σ drift across the production batch, and trace each unit by serial
Hardware Components
Motor Test Bench
The RCbenchmark Series 1585 (Tyto Robotics) is the production reference for sub-5 kgf motor classes. Per its datasheet it integrates a 5 kgf thrust load cell, 2 N·m torque transducer, 50 V / 55 A continuous electrical measurement (60 A burst), 50-80 Hz force sampling, and an optical RPM probe in one fixture. The board itself generates standard PWM (1000-2000 µs) throttle signals; digital protocols came from a separate RC Control Board accessory that Tyto has since discontinued. For new lines, the current Flight Stand family exposes an official Python API in its software, which is the integration path this template's plug models.

Bench-top stand: thrust load cell behind the motor mount, optical RPM probe at the hub, one USB link to the station.
For larger airframes (cargo, heavy-lift) the Tyto Robotics Flight Stand 15-150 kgf family scales the same architecture to industrial drones. For dedicated dyno testing without thrust, an ODrive Pro/S1 controller can drive an absorber motor back-to-back for direct torque-constant measurement.
Power Supply
A Rigol DP832 (triple output, 2 × 30 V / 3 A plus 5 V / 3 A) suffices for sub-100 W bench characterization. For full-power production tests on 6S packs, use a BK Precision 9205B (60 V / 25 A / 600 W) or an EA Elektro-Automatik PSI 9040-60 (40 V / 60 A, 1.5 kW); note that a 6S wide-open-throttle pull on a typical 2207-class motor exceeds what smaller 360 W supplies deliver. Check the vendor's load-regulation and transient-recovery specs against your peak current step, otherwise voltage-sag-induced KV measurement error dominates the result.
RPM Measurement
Two methods run in parallel:
- Optical reference: a reflective strip on the prop hub and an optical probe on the bench (the Series 1585 integrates one with 1 eRPM resolution per its datasheet).
- eRPM telemetry: bidirectional DShot frames returned by the ESC over the same signal wire. Trustworthy only after we validate against optical. Following the Betaflight convention, mechanical RPM = eRPM ÷ (motor poles ÷ 2); check which convention your bench software reports before comparing columns.
Running both lets us also validate that the ESC's self-reported eRPM matches reality, catching pole-count misconfigurations and DShot bit errors.
Custom Firmware
The DUT ESC must ship with bidirectional DShot enabled (BLHeli_32 32.7.0+ or AM32) so the test station can read eRPM directly from the signal line. No special firmware build is needed; production firmware as it ships is what we test against. The flight controller does not participate in this test; the bench generates throttle signals and reads eRPM in its place.
Test Procedure
Overview
After mounting the DUT and connecting power, the procedure runs three phases in dependency order:
- Connect to the bench and verify ESC identity (firmware version, DShot rate).
- Sweep throttle 0-100% in 5% steps, log RPM and current, fit the curve, compute deadband and KV.
- Capture a 0-to-100% throttle step at 1 kHz and measure the rise time, cross-checking the sweep phase's results.
This template runs end to end against a simulated bench plug, so tofupilot run . gives a green run with no hardware attached. It also demonstrates three framework capabilities worth knowing: previous-results injection (the step-response phase reads the sweep phase's measurements by naming a parameter after its key), a dual-axis chart (RPM and current on one interactive plot), and custom aggregation types (r_squared is validated like any built-in statistic).
Why TofuPilot Framework?
TofuPilot Framework is a YAML + Python test framework built for hardware manufacturing. Instead of writing all your test logic, measurements, and limits inside Python code, you describe what the test does in a procedure.yaml file, and how in small Python phase files. The framework handles:
- Automatic Python environment management (via
uv) - Operator UI (no frontend code needed)
- Measurement validation and live charts
- Process isolation between phases and equipment plugs
Project Structure
The whole procedure is six small files:
You can find the full source on GitHub.
The Procedure File
procedure.yaml declares the unit, the bench plug, the phases in dependency order, and every measurement with its limits. Two validator operators worth noting: the firmware version is a string measurement checked with a matches regex, and the DShot rate is a numeric measurement checked with the in operator against the set of supported rates:
name: ESC Throttle-to-RPM Characterizationversion: 0.1.0description: Maps ESC throttle commands to motor RPM on a production bench, validating linearity, deadband, step response, and KV per unit.unit: auto_identify: true serial_number: default_value: "SN00001" part_number: default_value: "ESC-2207"plugs: - name: Motor Test Bench description: Simulated RCbenchmark-class motor test bench that generates throttle sweep and step response data. python: plugs.bench:MockBench key: benchmain: - name: Connect Bench key: connect_bench python: phases.connect_bench measurements: - name: ESC Firmware Version key: esc_firmware_version validators: - operator: matches expected_value: "^(BLHeli_32 32\\.\\d+|AM32 v\\d+\\.\\d+)$" - name: DShot Rate key: dshot_rate unit: kbps validators: - operator: in expected_value: [150, 300, 600, 1200]The connect phase reads both values off the bench and assigns them; the framework types each measurement from the assigned value and evaluates the validators at phase close:
def connect_bench(bench, measurements, log): log.info("Connecting to motor test bench") firmware = bench.get_firmware_version() measurements.esc_firmware_version = firmware log.info(f"ESC firmware: {firmware}") measurements.dshot_rate = bench.get_dshot_rate()Throttle Sweep
We step throttle in 5% increments from 0 to 100%, dwelling at each step to let RPM settle. The sweep fills a multi-dimensional measurement with two y-axes, RPM and current against the same throttle axis, which the dashboard renders as one interactive dual-axis chart. The linearity R² is attached to the RPM axis as a custom aggregation: aggregation types are free-form strings, so r_squared is declared and validated exactly like mean or std would be:
- name: Throttle Sweep key: throttle_sweep python: phases.throttle_sweep depends_on: - connect_bench measurements: - name: Throttle Response key: throttle_response title: RPM and Current vs Throttle description: Up-sweep 0-100% in 5% steps, last-second average per step. x_axis: legend: Throttle unit: "%" y_axis: - legend: RPM key: rpm unit: RPM aggregations: - type: r_squared validators: - operator: ">=" expected_value: 0.98 - legend: Current key: current unit: A - name: Deadband key: deadband_pct unit: "%" validators: - operator: "<=" expected_value: 5.0 - name: KV Measured key: kv_measured unit: RPM/V validators: - operator: ">=" expected_value: 1710 - operator: "<=" expected_value: 1890
Each step dwells long enough for RPM to settle; only the last second is averaged into the sweep point.
The phase drives the bench, assigns both axes, and sets the aggregation value that the framework then validates. On KV: the mock behaves as an unloaded motor, so max RPM ÷ bus voltage recovers the true KV and the 1800 ±5% validators apply directly. On a real bench with the propeller installed, the same computation yields a loaded proxy; keep the computation, but derive its limits from a golden reference unit rather than the motor datasheet:
from utils.fit_linearity import fit_linearitydef throttle_sweep(bench, measurements, log): log.info("Sweeping throttle 0-100% in 5% steps") throttle, rpm, current = bench.sweep(0, 100, 5) md = measurements.throttle_response md.x_axis = throttle md.y_axis.rpm = rpm md.y_axis.current = current fit = fit_linearity(throttle, rpm, low_pct=10) md.y_axis.rpm.aggregations.r_squared = fit["r_squared"] log.info(f"Linearity R² = {fit['r_squared']:.4f}") deadband = next( (t for t, r in zip(throttle, rpm) if r > 0.0), 100.0 ) measurements.deadband_pct = float(deadband) voltage = bench.get_bus_voltage() measurements.kv_measured = max(rpm) / voltageLinearity Fit
The linear model covers the 10-100% throttle region, excluding the deadband. Drone-grade motors give R² > 0.98 with a clean fit; a sub-spec R² typically points to a noisy ESC commutation table or a winding imbalance.
import numpy as npdef fit_linearity(throttle, rpm, low_pct=10): """Least-squares linear fit on the region above the deadband. Returns slope, intercept, and R² of RPM vs throttle. """ t = np.asarray(throttle, dtype=float) r = np.asarray(rpm, dtype=float) mask = t >= low_pct t, r = t[mask], r[mask] slope, intercept = np.polyfit(t, r, 1) predicted = slope * t + intercept ss_res = float(np.sum((r - predicted) ** 2)) ss_tot = float(np.sum((r - np.mean(r)) ** 2)) r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0 else 1.0 return { "slope": float(slope), "intercept": float(intercept), "r_squared": r_squared, }Step Response
The final phase issues a 0-to-100% throttle step, captures RPM at 1 kHz, and measures time to 95% target. As rules of thumb, racing-class motors reach 95% in around 50 ms and cinematic builds allow around 100 ms; treat both as starting points to refine against your own golden units. Slow step response indicates an ESC PID tuned for low-noise operation or an oversized prop on an undersized motor.
The phase declares its limit and its dependency in the YAML; depends_on is what guarantees the sweep phase has completed before its results are read:
- name: Step Response key: step_response python: phases.step_response depends_on: - throttle_sweep measurements: - name: Rise Time key: rise_time_ms unit: ms validators: - operator: "<=" expected_value: 100The phase also demonstrates previous-results injection: naming a function parameter after the throttle_sweep phase key gives read access to that phase's recorded measurements, outcome, and duration, without any shared state or files:
import numpy as npdef step_response(bench, measurements, log, throttle_sweep): # Previous-results injection: the parameter named after the # throttle_sweep phase key exposes its measurements and outcome. log.info( f"Sweep phase finished {throttle_sweep.outcome} in " f"{throttle_sweep.duration_ms} ms, deadband " f"{throttle_sweep.deadband_pct}%, KV {throttle_sweep.kv_measured:.0f}" ) if throttle_sweep.outcome != "pass": log.warning("Sweep did not pass, step response may be unreliable") t_ms, rpm = bench.step_capture(1000) target = 0.95 * max(rpm) rise_time = next(t for t, r in zip(t_ms, rpm) if r >= target) measurements.rise_time_ms = float(rise_time) log.info(f"0-95% rise time: {rise_time:.1f} ms")
The mock's 18 ms time constant puts the 95% point at 57 ms, well inside the 100 ms limit.
Bench Plug
A plug is a persistent Python class for a device or service. The framework creates it in its own process at the start of the run, injects it into phases by parameter name, and tears it down at the end. This template ships a MockBench that generates a synthetic ESC + motor response (1800 KV, 3.5% deadband, 18 ms time constant), so the whole procedure runs and passes without hardware. To run against a real bench, swap the class for one built on the Flight Stand software's Python API; the phases stay unchanged.
import numpy as npBUS_VOLTAGE = 16.0 # 4S nominalKV_TRUE = 1802.0 # RPM/V of the simulated motorDEADBAND_TRUE = 3.5 # % throttle before rotation startsTAU_MS = 18.0 # first-order time constant of the RPM responseclass MockBench: def __init__(self): self.rng = np.random.default_rng(42) print("Mock bench initialized") def get_firmware_version(self): return "BLHeli_32 32.10" def get_dshot_rate(self): return 600 def get_bus_voltage(self): return BUS_VOLTAGE def sweep(self, start_pct, stop_pct, step_pct): """Return (throttle %, RPM, current A) lists for an up-sweep.""" throttle = list(range(start_pct, stop_pct + 1, step_pct)) rpm = [] current = [] max_rpm = KV_TRUE * BUS_VOLTAGE for t in throttle: if t <= DEADBAND_TRUE: r = 0.0 i = 0.08 else: effective = (t - DEADBAND_TRUE) / (100.0 - DEADBAND_TRUE) r = max_rpm * effective + self.rng.normal(0, 40) i = 0.1 + 22.0 * effective**2 + self.rng.normal(0, 0.05) rpm.append(round(max(r, 0.0), 1)) current.append(round(max(i, 0.0), 3)) return throttle, rpm, current def step_capture(self, sample_rate_hz): """Return (time ms, RPM) for a 0-to-100% throttle step.""" n = int(0.2 * sample_rate_hz) # 200 ms window t_ms = [i * 1000.0 / sample_rate_hz for i in range(n)] max_rpm = KV_TRUE * BUS_VOLTAGE rpm = [ max_rpm * (1.0 - np.exp(-t / TAU_MS)) + float(self.rng.normal(0, 60)) for t in t_ms ] return t_ms, [round(max(r, 0.0), 1) for r in rpm]Run It
tofupilot run .The CLI provisions the Python environment with uv, starts the bench plug, and executes the three phases. Headless verification for CI uses the JSON event stream: tofupilot run . --no-tui --json ends with {"outcome":"PASS","exit_code":0}.