Skip to content

Propeller Thrust and Efficiency Curve

Measure drone propeller thrust, torque, and efficiency across the throttle range at end-of-line to validate propulsion performance per unit.

TofuPilotEnd-of-LinePythonTofuPilot FrameworkGitHub
Propeller Thrust and Efficiency Curve

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.

Propulsion chain bar chart: battery to ESC to motor to propeller, with typical per-stage efficiencies of 95, 92, 85 and 75 percent leaving about 56 percent of pack power as thrust.

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.

System efficiency in g/W versus thrust across the 10-100% throttle sweep of the mock 9.45-inch prop, with the hover point at 50% throttle and 8.7 g/W marked against the 8 g/W limit.

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).

Heavy-lift thrust stand with a large two-blade propeller on the pendulum arm, load cell at the rear of the arm and the torque arm extending from the pedestal.

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:

  1. Setup: verify thrust and torque zero offsets, record line, prop lot, and ambient temperature as run metadata.
  2. Main: stepped throttle sweep 10-100% in 10% steps, logging thrust, torque, RPM, V, I, and both temperature probes.
  3. Main: compute the efficiency curve, hover-point efficiency, peak thrust, and figure of merit.
  4. 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

procedure.yaml
setup
zero_bench.py
phases
throttle_sweep.py
compute_efficiency.py
teardown
power_down.py
plugs
bench.py
temp_probe.py
utils
propulsion.py
pyproject.toml

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:

procedure.yaml
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: 1

The config keys become keyword arguments on the plug's __init__:

plugs/temp_probe.py
"""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:

procedure.yaml
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.0
setup/zero_bench.py
def 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.4

Throttle 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:

procedure.yaml
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

Stacked time series of the 60 s throttle sweep: throttle staircase, RPM following with a short lag, thrust rising in steps to 1838 g, and stator temperature climbing from 22 to 52 °C under the 85 °C limit.

One sweep as the bench sees it: each throttle step settles before the next, and the stator warms through the run.

phases/throttle_sweep.py
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.

Dual-axis plot of efficiency in g/W falling and thrust in grams rising across throttle, with the 35-50% hover band shaded, peak thrust 1838 g inside the 1748-1932 g window and a figure of merit of 0.64.

Efficiency and thrust on one throttle axis: the hover band is where endurance is decided, WOT is where peak thrust is checked.

procedure.yaml
- 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.55
phases/compute_efficiency.py
import 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}")
utils/propulsion.py
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:

procedure.yaml
teardown:  - name: Power Down    key: power_down    python: teardown.power_down
teardown/power_down.py
def 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}.

Run your first test in minutes