
Introduction
Propulsion Overview
A drone's propulsion system is a chain: battery → ESC → motor → propeller → air. Each stage introduces loss, and the only output that matters in flight is thrust per watt of electrical power. A 6 g/W system at hover gives a 1 kg drone roughly 14 minutes of endurance on a 3,000 mAh 4S pack; a 9 g/W system stretches that to 22 minutes (both assuming ~80% usable pack capacity). The same hardware can land anywhere in this range depending on prop pitch, motor KV, ESC efficiency, and assembly quality.

Typical per-stage efficiencies compound: only about half of the pack's power ends up moving air.
End-of-line propulsion testing characterizes thrust, torque, electrical power, and efficiency as functions of throttle and RPM. The standard propeller coefficients C_T (thrust) and C_P (power), and the figure of merit (FoM) from the rotorcraft hover convention (McCormick), distill prop performance into dimensionless numbers comparable across diameters. The UIUC Propeller Data Site (Brandt, Deters, Ananda, Dantsker, Selig) holds wind-tunnel data for hundreds of small UAV and model aircraft propellers and is the de-facto reference database.
Test Purpose
Per-unit propulsion testing catches variance that incoming inspection cannot:
- Motor KV drifts ±3-5% from winding tension and magnet placement
- Injection-molded plastic props drift in pitch by lot; CNC carbon-reinforced props vary less but show mass imbalance that excites resonance
- ESC switching losses spread across FET vendor lots
- Assembly defects: misaligned shaft, cracked prop hub, loose magnet, mis-soldered phase wire raising iron/copper losses
The output of the test is a per-unit propulsion fingerprint that compares to a golden reference. A drone delivering only 92% of golden thrust at WOT is rejected before shipping instead of reaching a customer as an underperforming unit.

Efficiency falls as thrust rises; the hover point is where the per-motor thrust meets a quarter of the all-up weight.
Equipment & Setup
To implement propeller thrust + efficiency testing on a production line, the following are required:
- A motor + prop test bench with thrust load cell, reaction torque transducer, RPM tach, and full electrical metrology
- A regulated DC power supply matched to pack voltage and peak current
- The Device Under Test (DUT): a complete motor + prop + ESC assembly mounted to the bench
- A TofuPilot Framework procedure to sweep throttle, compute coefficients, and validate per-unit performance
- The TofuPilot Dashboard to log results and trend the production line
Hardware Components
Test Bench
For sub-5 kgf classes (FPV, sub-2 kg commercial), the RCbenchmark Series 1585 is the production standard: 5 kgf thrust, 2 N·m torque, 50 V / 55 A electrical measurement, 50-80 Hz force sample rate, integrated optical RPM probe. For larger airframes the Tyto Robotics Flight Stand 50 scales to 50 kgf / ±30 N·m at 100 Hz sampling (1 kHz on the Pro version), with a calibration methodology derived from ASTM E74/E2624 (Tyto Methodology V2.1, >200-point matrix on Pro, verification report on request).

Pendulum thrust stand: the load cell reads the arm's reaction, the torque arm reads the motor's.
Sensors: strain-gauge load cell for thrust (pendulum or flexure mount), reaction torque transducer sized to the prop class (a 9-10 inch prop at WOT produces ~0.25 N·m, so pick a Transducer Techniques RTS-200 class sensor, about 1.4 N·m, for healthy margin; note the RTS range is specified in inch-ounces), optical or Hall-effect tachometer for RPM, two thermocouples or NTC probes on motor stator and ESC. High-fidelity electrical metrology benefits from a dedicated Yokogawa WT1800E power analyzer (basic power accuracy 0.1% of reading + 0.05% of range) with harmonic content capture, useful for differentiating ESC switching losses from motor copper losses.
Power Supply
Production benches typically use an EA Elektro-Automatik PSI 9040-60 (40 V / 60 A, 1.5 kW) for 6S drone classes. Check the vendor's load-regulation and transient-recovery specs against your high-throttle current step, otherwise voltage sag during the dwell skews the efficiency calculation. Battery-powered testing is acceptable only if pack voltage is logged and the efficiency calculation references the live bus voltage at each sample.
Lighting and Environment
Bench lighting must not interfere with the optical RPM probe (no flicker, no 60 Hz fluorescents in the optical path). Ambient temperature controlled to 22 ± 2 °C, because air density changes ~1% per 3 °C and directly affects static thrust at constant RPM (less so at constant power).
Custom Firmware
The ESC ships its production firmware (AM32, BLHeli_32, BLHeli_S) with bidirectional DShot enabled for eRPM telemetry. The flight controller is not in the loop; the bench directly issues DShot or PWM commands and records eRPM frames back. Motor temperature is read either from the ESC telemetry NTC or from a dedicated thermocouple on the stator can.
Test Procedure
Overview
The procedure is organized in the framework's three stages. Setup zeroes the bench with the motor un-energized and stamps run metadata. Main sweeps the throttle and computes the efficiency metrics. Teardown powers the bench down, and it runs even when a main phase fails, so a failed unit never leaves the bench armed:
- Setup: verify thrust and torque zero offsets, record line, prop lot, and ambient temperature as run metadata.
- Main: stepped throttle sweep 10-100% in 10% steps, logging thrust, torque, RPM, V, I, and both temperature probes.
- Main: compute the efficiency curve, hover-point efficiency, peak thrust, and figure of merit.
- Teardown: disarm the bench, always.
This template runs end to end against simulated plugs, so tofupilot run . gives a green run with no hardware attached. It also demonstrates four framework capabilities: setup and teardown stages, plug config mappings (instrument addresses live in YAML, not Python), multiple instances of one plug class (two thermocouple probes on different channels), and run metadata you can filter by on the dashboard.
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
You can find the full source on GitHub.
The Procedure File
procedure.yaml declares three plugs. The flight stand takes a config mapping, so its address and sample rate are procedure configuration rather than hard-coded Python. The two thermocouple probes are two instances of the same plug class, each with its own key and channel; phases receive each instance by parameter name:
name: Propeller Thrust and Efficiency Curveversion: 0.1.0description: Measures propeller thrust, torque, and efficiency across the throttle range at end-of-line, with figure of merit and hover-point validation.unit: auto_identify: true serial_number: default_value: "SN00001" part_number: default_value: "PROP-9450"plugs: - name: Flight Stand description: Simulated Tyto-class thrust stand with electrical metrology. python: plugs.bench:MockFlightStand key: bench config: address: "192.168.1.60" sample_rate_hz: 80 - name: Stator Probe key: temp_stator python: plugs.temp_probe:TempProbe config: channel: 0 - name: ESC Probe key: temp_esc python: plugs.temp_probe:TempProbe config: channel: 1The config keys become keyword arguments on the plug's __init__:
"""Thermocouple probe. Registered twice in procedure.yaml with differentchannels, showing multiple instances of one plug class."""import numpy as npclass TempProbe: def __init__(self, channel): self.channel = channel self.rng = np.random.default_rng(100 + channel) self._base = 22.0 print(f"Temp probe on channel {channel}") def read_series(self, n): """Return n samples of a slow thermal rise in °C.""" rise = 30.0 if self.channel == 0 else 18.0 return [ round(self._base + rise * (i / max(n - 1, 1)) ** 1.5 + float(self.rng.normal(0, 0.2)), 2) for i in range(n) ]Setup: Zero the Bench
The setup: stage runs before main and must pass for the test to proceed. Zero offsets outside their limits mean a drifted load cell, and there is no point sweeping a motor against a bad reference. The phase also writes run metadata, key-value pairs stored on the run and filterable on the dashboard:
setup: - name: Zero Bench key: zero_bench python: setup.zero_bench measurements: - name: Thrust Zero Offset key: thrust_zero_g unit: g validators: - operator: "<=" expected_value: 2.0 - name: Torque Zero Offset key: torque_zero_mnm unit: mN·m validators: - operator: "<=" expected_value: 5.0def zero_bench(bench, measurements, log, run): log.info("Zeroing thrust and torque with motor un-energized") offsets = bench.zero_offsets() measurements.thrust_zero_g = offsets["thrust_g"] measurements.torque_zero_mnm = offsets["torque_mnm"] # Run metadata is filterable on the dashboard. run.metadata["line"] = "EOL-2" run.metadata["prop_lot"] = "PL-2620-A" run.metadata["ambient_temp_c"] = 22.4Throttle Sweep
10-100% in 10% steps. The thrust curve is a multi-dimensional measurement rendered as an interactive chart. The stator temperature series is a numeric array measurement carrying four aggregations, min, max, mean, and a custom p2p, each with its own validators. Aggregation types are free-form strings, so any statistic your Python computes can be declared and validated:
main: - name: Throttle Sweep key: throttle_sweep python: phases.throttle_sweep measurements: - name: Thrust Curve key: thrust_curve title: Thrust vs Throttle x_axis: legend: Throttle unit: "%" y_axis: - legend: Thrust key: thrust unit: g - name: Stator Temperature key: stator_temp_c unit: "°C" aggregations: - type: min validators: - operator: ">=" expected_value: 15.0 - type: max validators: - operator: "<=" expected_value: 85.0 - type: mean validators: - operator: "<=" expected_value: 60.0 - type: p2p validators: - operator: "<=" expected_value: 60.0
One sweep as the bench sees it: each throttle step settles before the next, and the stator warms through the run.
import numpy as npdef throttle_sweep(bench, temp_stator, temp_esc, measurements, log): bench.arm() log.info("Sweeping throttle 10-100% in 10% steps") data = bench.sweep(10, 100, 10) md = measurements.thrust_curve md.x_axis = data["throttle"] md.y_axis.thrust = data["thrust_g"] temps = temp_stator.read_series(len(data["throttle"])) esc_temps = temp_esc.read_series(len(data["throttle"])) log.info(f"Stator {temps[-1]}°C, ESC {esc_temps[-1]}°C at end of sweep") measurements.stator_temp_c = temps aggs = measurements.stator_temp_c.aggregations aggs.min = float(np.min(temps)) aggs.max = float(np.max(temps)) aggs.mean = float(np.mean(temps)) aggs.p2p = float(np.ptp(temps))Efficiency and Figure of Merit
Per sweep step, the phase computes electrical power P_elec = V_bus · I_bus and system efficiency η_sys = Thrust(g) / P_elec(W) in g/W. At wide-open throttle it additionally computes mechanical power P_mech = τ · ω to form the figure of merit FoM = C_T^1.5 / (C_P · √2), the rotorcraft hover convention.
The efficiency curve peaks below mid-throttle (system optimum) and falls toward WOT. The hover-point efficiency (throttle where thrust per motor equals ¼ AUW for a quad) is the single most important number for endurance; published propulsion test tables such as T-Motor's per-throttle g/W data put well-matched systems in the 8-10 g/W band at low throttle, which is where this template's ≥8 g/W hover limit comes from. Calibrate it against your own golden unit.
Note the second phase reads the sweep data with bench.last_sweep(): a plug instance persists across phases within the run, so the sweep captured in phase one is served from plug state in phase two without re-running the motor.

Efficiency and thrust on one throttle axis: the hover band is where endurance is decided, WOT is where peak thrust is checked.
- name: Compute Efficiency key: compute_efficiency python: phases.compute_efficiency depends_on: - throttle_sweep measurements: - name: Efficiency Curve key: efficiency_curve title: System Efficiency vs Throttle x_axis: legend: Throttle unit: "%" y_axis: - legend: Efficiency key: efficiency unit: g/W - name: Hover Efficiency key: hover_efficiency_gw unit: g/W validators: - operator: ">=" expected_value: 8.0 - name: Peak Thrust key: peak_thrust_g unit: g validators: - operator: ">=" expected_value: 1748 - operator: "<=" expected_value: 1932 - name: Figure of Merit key: figure_of_merit validators: - operator: ">=" expected_value: 0.55import numpy as npfrom utils.propulsion import figure_of_merit, system_efficiencydef compute_efficiency(bench, measurements, log): data = bench.last_sweep() throttle = data["throttle"] eff = system_efficiency(data) md = measurements.efficiency_curve md.x_axis = throttle md.y_axis.efficiency = eff # Hover point: thrust per motor equals 1/4 AUW of a 1.6 kg quad. hover_thrust = 400.0 hover_idx = int(np.argmin(np.abs(np.asarray(data["thrust_g"]) - hover_thrust))) measurements.hover_efficiency_gw = eff[hover_idx] log.info(f"Hover point at {throttle[hover_idx]}% throttle, " f"{eff[hover_idx]:.1f} g/W") measurements.peak_thrust_g = max(data["thrust_g"]) fom = figure_of_merit(data, prop_diameter_m=0.240) measurements.figure_of_merit = fom log.info(f"Figure of merit at WOT: {fom:.2f}")import numpy as npRHO = 1.225 # air density kg/m^3G = 9.80665def system_efficiency(data): """Thrust (g) per electrical watt at each sweep step.""" thrust = np.asarray(data["thrust_g"], dtype=float) p_elec = np.asarray(data["volts"], dtype=float) * np.asarray( data["amps"], dtype=float ) return [round(float(t / max(p, 0.1)), 2) for t, p in zip(thrust, p_elec)]def figure_of_merit(data, prop_diameter_m): """FoM = C_T^1.5 / (C_P * sqrt(2)) at wide-open throttle (rotorcraft hover convention, see McCormick).""" n = data["rpm"][-1] / 60.0 # rev/s d = prop_diameter_m thrust_n = data["thrust_g"][-1] / 1000.0 * G torque_nm = data["torque_nm"][-1] p_mech = torque_nm * 2 * np.pi * n c_t = thrust_n / (RHO * n**2 * d**4) c_p = p_mech / (RHO * n**3 * d**5) return float(c_t**1.5 / (c_p * np.sqrt(2)))Teardown: Power Down
The teardown: stage runs no matter how main ended. A unit that fails its efficiency limits still leaves the bench disarmed:
teardown: - name: Power Down key: power_down python: teardown.power_downdef power_down(bench, log): # Teardown always runs, even when a main phase fails. bench.disarm() log.info("Bench powered down")Run It
tofupilot run .The bundled MockFlightStand simulates a 9.45-inch (240 mm) prop from momentum theory (rotor figure of merit 0.72, drive efficiency 0.75), so the run passes with no hardware. Swap plugs/bench.py for a real Tyto or RCbenchmark driver to go live; phases and limits stay unchanged. Headless verification for CI: tofupilot run . --no-tui --json ends with {"outcome":"PASS","exit_code":0}.