Actuator End-of-Line Dyno Test

End-of-line dyno test of a humanoid joint actuator: run-in no-load torque, torque constant under stall, efficiency map, torque ripple and its dominant order.

TofuPilotEnd-of-LinePythonTofuPilot FrameworkGitHub
Actuator and load motor coupled on a guarded dynamometer test bed
Run this procedure.

Get the complete source, dependencies and setup instructions from the template repository.

Open the source on GitHub ↗

Introduction

Actuator End-of-Line Test Overview

A humanoid has thirty to forty of them: a frameless motor, a strain-wave or cycloidal reducer, an output encoder and a drive, packed into one joint module. The actuator is the part of the robot that gets built in volume, and the joint module end-of-line test is where the actuator maker or the robot maker proves each one before it goes into a leg. The vocabulary in the job postings of Figure, Apptronik, Tesla and Agility is consistent: a dyno (dynamometer) with a torque transducer and a power analyzer, driven over EtherCAT from a real-time target, measuring efficiency, stiffness, backlash, thermal limits and producing pass/fail criteria per unit.

A compact cylindrical joint actuator on a plate, its output flange with a bolt circle and the copper gear ring of the reducer facing the viewer, a small green drive PCB on the back of the can.

One joint actuator: the output flange with its bolt circle, the reducer's gear ring behind it, the frameless motor inside the can, the drive PCB on the back.

The numbers to build the test on are the actuator's own datasheet, cross-checked against measurement. A characterization paper on a quasi-direct-drive actuator measured the motor torque constant at 0.105 ± 0.002 Nm/A under stall with a transducer and mapped efficiency over 1838 operating points (arXiv 2202.12395); Harmonic Drive specifies the no-load running torque of its gear units after a run-in of 2 h at 2000 rpm input at 20 °C (SHD catalog); a strain-wave reducer puts two cycles of torque ripple per input revolution on the output, so a healthy unit's dominant ripple order is twice the ratio. The Kistler 4503B transducer that reads all of this is class 0.05 with ±0.03° of angle, so the bench is never the limit.

Test Purpose

The procedure records one performance fingerprint per actuator:

  • Drive identity and configuration before the dyno moves: firmware, current limit, pole pairs, commutation mode
  • No-load running torque through a run-in, input-referred, with its settled value limited
  • Output torque constant from a locked-rotor current sweep, with the friction knee and the linearity
  • Efficiency at sixteen operating points, four speeds by four torques, from shaft power over bus power
  • Torque ripple over one output revolution, as a percentage of rated torque and as a dominant order
  • Housing temperature at the start, after the run-in and at the end

Left, the input-referred no-load torque settling from 0.19 to 0.12 Nm over the 10 minute run-in under the 0.20 Nm limit. Right, output torque against q-axis current under stall, six points on a line with a 1.7 Nm friction knee, the fit at 8.99 Nm/A next to the lossless 10.5 Nm/A line.

The mock actuator's first two main phases: the no-load torque settling as the grease finds its film, and the stall curve whose slope above the knee is the output-referred torque constant, 86 % of motor Kt times ratio.

The framework mechanics on show are a progress component on a time-scaled long phase, multi-dimensional measurements with derived aggregations, three curves in one measurement with the limits on one, an integer aggregation validated with == (the FFT order), string matches and JSON == on the drive before power, and setup and teardown stages around a depends_on chain.

Equipment & Setup

To run this test on an actuator line, the following are required:

  • A dynamometer: a four-quadrant servo load or an eddy-current brake sized for the actuator's peak torque and speed
  • An inline torque transducer with an angle output, class 0.1 or better
  • A power analyzer on the DC bus
  • A real-time target running the actuator's EtherCAT drive
  • The Device Under Test (DUT): an assembled joint actuator with its production firmware
  • A TofuPilot Framework procedure to sequence the dyno, compute the derived numbers and validate the limits
  • The TofuPilot Dashboard to trend Kt, efficiency and ripple across actuators and lots

Hardware Components

Dynamometer and Transducer

Magtrol builds frameless-motor and actuator benches from a WB eddy-current dynamometer, a TM 300 inline torque transducer and a custom fixture; a servo motor on a second drive makes a four-quadrant load that can also back-drive the actuator. The Kistler 4503B is the reference transducer: dual range 0.2 to 5000 Nm, class 0.05, angle to ±0.03°, 10 kHz bandwidth; HBM's T40B is the alternative at 0.1 % of full scale. Figure's test-development posting names "analog torque cells (HBM)" and "high-resolution absolute encoders (BiSS-C)" on a Speedgoat target with Elmo and Kollmorgen drives; that is the bench this template models.

Actuator dyno station: the joint actuator in a bracket coupled through an inline torque transducer to a larger load motor on one axis, cabled to a bench instrument with a red pushbutton on the right.

A single-axis station: the actuator in its bracket, the transducer between it and the load, the drive and the analyzer in the instrument on the right.

Drive and Bus

The actuator's own EtherCAT drive commands q-axis current for the stall sweep and speed for the map; the bus power analyzer reads the electrical input. An Apptronik posting lists "peak current, thermal limits" and "motor and encoder calibration" among the things the actuation test engineer owns; the drive configuration is read before the dyno moves because a wrong pole-pair count or current limit runs, produces torque numbers, and produces the wrong ones.

Where the Limits Come From

No standard specifies an actuator end-of-line test. GB/T 35089-2018 gives a bench method for robot precision reducers and GB/T 30819-2024 covers harmonic reducers for robots; IEC 60034-1 gives the motor's thermal classes; ISO 9283 tests the assembled robot. The limits in this template are the actuator's datasheet and the pack maker's own:

TestLimitBasis
No-load running torque≤ 0.20 Nm input-referred at 2000 rpm, after run-inreducer datasheet convention (value after 2 h at 2000 rpm)
Output torque constant8.5 to 9.6 Nm/Amotor Kt 0.105 × ratio 100 × locked-rotor efficiency 0.81 to 0.91
Stall linearity≤ 2 %transducer class 0.05, the rest is the actuator
Friction knee≤ 3 Nmreducer static friction at the output
Efficiency at rated≥ 72 %datasheet 80 % at 60 Nm / 30 rpm, minus tolerance
Efficiency map minimum≥ 50 %light-load corner of the map
Torque ripple≤ 3 % of rated p-ppack maker's spec; vendor guidance quotes 0.5 to 2 %
Dominant ripple order== 2002 × ratio for a strain-wave reducer

The vendor-article figures that circulate ("65 to 80 % efficiency", "15 to 30 minutes per joint") are guidance without test data; the datasheet and the measured Kt are the anchors.

Test Procedure

Overview

The procedure maps the test onto the framework's three stages. The drive readback lives in setup: so no torque number is recorded on a mis-configured drive. Parking lives in teardown: so the dyno unloads and the drive disables whatever happened.

  1. Setup: firmware, drive configuration, housing temperature.
  2. Main: run-in at 2000 rpm input, no-load torque curve, settled value.
  3. Main: locked-rotor current sweep, Kt, friction knee, linearity.
  4. Main: sixteen-point efficiency map.
  5. Main: one revolution at 3 rpm under load, ripple and dominant order.
  6. Teardown: unload, disable, temperature.

Every metric validates against limits declared in procedure.yaml, and results stream to TofuPilot for trending.

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
phases
identify.py
run_in.py
torque_constant.py
efficiency_map.py
torque_ripple.py
park.py
plugs
actuator_bench.py
utils
recipe.py
pyproject.toml
README.md

You can find the full source on GitHub. The ActuatorBench plug is a mock of the dyno, the transducer, the power analyzer and the drive together, synthesizing a healthy actuator on datasheet values, so the procedure runs end-to-end without a bench or an actuator connected. The run-in is time-scaled: the mock returns the ten minutes in one call.

tofupilot run .

For CI or bench automation, the same run executes headless:

tofupilot run . --no-tui --no-kiosk --json

The Procedure File

procedure.yaml declares the unit, the bench plug, and the three stages with every measurement and limit:

procedure.yaml · 180 lines
procedure.yaml
name: Actuator End-of-Line Dyno Testversion: 0.1.0description: End-of-line dynamometer test of a humanoid joint actuator (frameless motor, 100:1 harmonic reducer, output encoder). Drive identity and configuration, time-scaled run-in with the no-load running torque, output torque constant under stall, sixteen-point efficiency map, torque ripple with order tracking over one revolution.unit:  auto_identify: true  serial_number:    description: "Scan the actuator housing label"    placeholder: "ACT-HIP-000000"    pattern: "^ACT-[A-Z]{3}-\\d{6}$"    default_value: "ACT-HIP-004812"  part_number:    default_value: "ACT-R100-60NM"  batch_number:    default_value: "WK-2026-37"plugs:  - name: Actuator Bench    description: "Four-quadrant dyno, inline torque transducer with angle, bus power analyzer, EtherCAT drive (mock, one plug per bench)"    python: plugs.actuator_bench:ActuatorBench    key: benchsetup:  - name: Identify    key: identify    python: phases.identify    measurements:      - name: Firmware Version        key: firmware_version        validators:          - {operator: matches, expected_value: "^3\\.\\d+\\.\\d+$"}      - name: Drive Config        key: drive_config        description: Current limit, pole pairs, commutation mode and brake setting as one object; a wrong pole-pair count runs and produces numbers, all of them wrong.        validators:          - operator: "=="            expected_value:              current_limit_a: 30              pole_pairs: 21              commutation: sincos              brake: none      - name: Temperature Start        key: temperature_start_c        unit: °C        validators:          - {operator: ">=", expected_value: 18.0}          - {operator: "<=", expected_value: 28.0}main:  - name: Run In    key: run_in    python: phases.run_in    timeout: 30m    ui:      components:        - key: run_in_progress          type: progress          label: "Run-in at 2000 rpm input"          description: "10 min unloaded, no-load torque logged every 10 s"          default_value: 0          max: 100    measurements:      - name: No-Load Torque        key: no_load        title: Input-referred no-load running torque during the run-in        x_axis:          legend: Time          unit: min        y_axis:          - legend: Torque            key: torque            unit: Nm            aggregations:              - type: final_nm                unit: Nm                validators:                  - {operator: "<=", expected_value: 0.20}              - type: settle_pct                unit: "%"      - name: Temperature After Run-In        key: temperature_after_run_in_c        unit: °C        validators:          - {operator: "<=", expected_value: 45.0}  - name: Torque Constant    key: torque_constant    python: phases.torque_constant    depends_on: [run_in]    measurements:      - name: Stall        key: stall        title: Output torque against q-axis current, dyno locked        x_axis:          legend: Current          unit: A        y_axis:          - legend: Torque            key: torque            unit: Nm            aggregations:              - type: kt_nm_per_a                unit: Nm/A                validators:                  - {operator: ">=", expected_value: 8.5}                  - {operator: "<=", expected_value: 9.6}              - type: linearity_pct                unit: "%"                validators:                  - {operator: "<=", expected_value: 2.0}              - type: friction_knee_nm                unit: Nm                validators:                  - {operator: "<=", expected_value: 3.0}  - name: Efficiency Map    key: efficiency_map    python: phases.efficiency_map    depends_on: [torque_constant]    timeout: 10m    measurements:      - name: Efficiency        key: efficiency        title: Efficiency at sixteen operating points, four speeds by four torques        x_axis:          legend: Point        y_axis:          - legend: Speed            key: speed            unit: rpm          - legend: Torque            key: torque            unit: Nm          - legend: Efficiency            key: efficiency            unit: "%"            aggregations:              - type: at_rated_pct                unit: "%"                validators:                  - {operator: ">=", expected_value: 72.0}              - type: min_pct                unit: "%"                validators:                  - {operator: ">=", expected_value: 50.0}  - name: Torque Ripple    key: torque_ripple    python: phases.torque_ripple    depends_on: [efficiency_map]    measurements:      - name: Ripple        key: ripple        title: Torque over one output revolution at 3 rpm under 30 Nm        x_axis:          legend: Angle          unit: °        y_axis:          - legend: Torque            key: torque            unit: Nm            aggregations:              - type: pp_pct_rated                unit: "%"                validators:                  - {operator: "<=", expected_value: 3.0}              - type: dominant_order                validators:                  - {operator: "==", expected_value: 200}teardown:  - name: Park    key: park    python: phases.park    measurements:      - name: Temperature End        key: temperature_end_c        unit: °C        validators:          - {operator: "<=", expected_value: 60.0}

Framework features to notice:

  1. Progress on a long phase. The run-in declares a progress component and a 30 minute timeout; the phase writes ui.run_in_progress and the operator sees where the run is. The mock returns the ten minutes in one call.
  2. Derived aggregations. The stall curve records six torque points; the pass/fail lives on kt_nm_per_a, linearity_pct and friction_knee_nm, all computed in Python from the fit and named in the YAML with their own limits.
  3. Three curves, limits on one. The efficiency measurement records speed, torque and efficiency per point; at_rated_pct and min_pct carry the limits.
  4. An FFT order as an integer ==. dominant_order == 200 is a physical signature: the wave generator of a 100:1 strain-wave reducer. A bearing defect or a damaged flexspline moves it.
  5. Drive readback before power. drive_config compares four registers as one object; firmware_version validates with matches.

Identify

The setup phase reads the drive's identity and configuration, the housing temperature, and stamps the reducer ratio and the encoder resolution onto the unit metadata:

phases/identify.py
def identify(measurements, bench, unit, log):    """Setup: EtherCAT link up, firmware and drive configuration read    before the dyno moves. A drive with the wrong pole-pair count or    current limit would produce plausible torque numbers at the wrong    operating point."""    ident = bench.drive_identify()    measurements.firmware_version = ident["firmware"]    measurements.drive_config = bench.drive_config()    measurements.temperature_start_c = bench.temperature_c()    unit.metadata["reducer_ratio"] = ident["ratio"]    unit.metadata["encoder_bits"] = ident["encoder_bits"]    log.info(f"Actuator {unit.serial_number}: fw {ident['firmware']}, ratio {ident['ratio']}:1, {ident['encoder_bits']}-bit output encoder, {measurements.temperature_start_c} C")

Run In

Ten minutes unloaded at 2000 rpm input, the no-load torque logged every 10 s. The value at the end is the one the reducer maker's datasheet is written against; the curve is recorded for the trend, and the percentage it settled by is an aggregation without a limit:

phases/run_in.py · 23 lines
phases/run_in.py
import numpy as npfrom utils.recipe import RUN_IN_INPUT_RPM, RUN_IN_MINUTESdef run_in(measurements, bench, ui, log):    """Unloaded run-in at 2000 rpm input. The no-load running torque is    only meaningful after the grease has redistributed, which is why the    reducer maker specifies it after a run; the value at the end of the    run is the one with a limit, the curve is there for the trend."""    bench.enable()    cap = bench.run_in(RUN_IN_INPUT_RPM, RUN_IN_MINUTES)    t = np.array(cap["time_s"])    torque = np.array(cap["torque_nm"])    ui.run_in_progress = 100    final = float(torque[t >= t[-1] - 60.0].mean())    measurements.no_load.x_axis = (t / 60.0).round(2).tolist()    measurements.no_load.y_axis.torque = cap["torque_nm"]    measurements.no_load.y_axis.torque.aggregations.final_nm = final    measurements.no_load.y_axis.torque.aggregations.settle_pct = float(100.0 * (torque[:3].mean() - final) / torque[:3].mean())    measurements.temperature_after_run_in_c = bench.temperature_c()    log.info(f"No-load torque {torque[0]:.3f} -> {final:.3f} Nm input-referred over {RUN_IN_MINUTES:.0f} min, {measurements.temperature_after_run_in_c} C")

Torque Constant

Dyno locked, q-axis current stepped from 0 to 25 A, output torque from the transducer at each step. Below the friction knee the reducer's static friction eats the torque; the fit above 10 A gives the output-referred torque constant, and the intercept gives the knee:

phases/torque_constant.py · 24 lines
phases/torque_constant.py
import numpy as npfrom utils.recipe import KT_CURRENT_STEPS_A, KT_OUTPUT_EXPECTEDdef torque_constant(measurements, bench, log):    """Dyno locked, q-axis current stepped, output torque read on the    transducer. The slope above the friction knee is the output-referred    torque constant: motor Kt times ratio times the locked-rotor    efficiency of the reducer."""    torque = [bench.stall_torque_nm(i) for i in KT_CURRENT_STEPS_A]    currents = np.array(KT_CURRENT_STEPS_A)    t = np.array(torque)    fit = currents >= 10.0  # above the friction knee    slope, intercept = np.polyfit(currents[fit], t[fit], 1)    resid = t[fit] - (slope * currents[fit] + intercept)    linearity = float(100.0 * np.abs(resid).max() / t[fit].max())    measurements.stall.x_axis = KT_CURRENT_STEPS_A    measurements.stall.y_axis.torque = t.round(3).tolist()    measurements.stall.y_axis.torque.aggregations.kt_nm_per_a = float(slope)    measurements.stall.y_axis.torque.aggregations.linearity_pct = linearity    measurements.stall.y_axis.torque.aggregations.friction_knee_nm = float(-intercept)    log.info(f"Kt {slope:.2f} Nm/A output-referred ({KT_OUTPUT_EXPECTED:.1f} before losses), linearity {linearity:.2f} %, friction knee {-intercept:.2f} Nm")

Efficiency Map

Four speeds by four torques, the dyno holding each point, shaft power from the transducer over electrical power from the bus analyzer:

phases/efficiency_map.py · 26 lines
phases/efficiency_map.py
import numpy as npfrom utils.recipe import MAP_SPEEDS_RPM, MAP_TORQUES_NM, RATED_SPEED_RPM, RATED_TORQUE_NMdef efficiency_map(measurements, bench, log):    """Sixteen operating points, four speeds by four torques, shaft power    from the transducer over electrical power from the bus analyzer."""    speeds, torques, effs = [], [], []    for s in MAP_SPEEDS_RPM:        for q in MAP_TORQUES_NM:            op = bench.operating_point(s, q)            speeds.append(s)            torques.append(q)            effs.append(100.0 * op["mech_w"] / op["elec_w"])    bench.unload()    effs = np.array(effs)    i_rated = [i for i, (s, q) in enumerate(zip(speeds, torques)) if s == RATED_SPEED_RPM and q == RATED_TORQUE_NM][0]    measurements.efficiency.x_axis = list(range(1, len(effs) + 1))    measurements.efficiency.y_axis.speed = speeds    measurements.efficiency.y_axis.torque = torques    measurements.efficiency.y_axis.efficiency = effs.round(2).tolist()    measurements.efficiency.y_axis.efficiency.aggregations.at_rated_pct = float(effs[i_rated])    measurements.efficiency.y_axis.efficiency.aggregations.min_pct = float(effs.min())    log.info(f"Efficiency {effs.min():.1f}..{effs.max():.1f} %, {effs[i_rated]:.1f} % at rated {RATED_TORQUE_NM:.0f} Nm / {RATED_SPEED_RPM:.0f} rpm")

A four by four grid of efficiency percentages from 65.2 % at 15 Nm and 45 rpm to 80.3 % at 60 Nm and 5 rpm, the rated point at 30 rpm and 60 Nm outlined in red at 79.1 %.

The mock actuator's map: efficiency falls toward light load as the reducer's fixed losses dominate, and slightly above rated speed. The two limits sit on the rated point and on the map's minimum.

Torque Ripple

One output revolution at 3 rpm under 30 Nm, torque against angle at 0.1°. The peak-to-peak is expressed as a percentage of rated torque; an FFT over the revolution gives the dominant order, which for a strain-wave reducer is twice the ratio, the wave generator's two lobes per input revolution:

phases/torque_ripple.py · 22 lines
phases/torque_ripple.py
import numpy as npfrom utils.recipe import RATED_TORQUE_NM, RATIO, RIPPLE_LOAD_NM, RIPPLE_SPEED_RPMdef torque_ripple(measurements, bench, log):    """One output revolution at 3 rpm under 30 Nm, torque against angle.    Peak-to-peak ripple as a percentage of rated torque, and the dominant    order from an FFT over the revolution: a healthy harmonic drive shows    2 x ratio (the wave generator), a damaged flexspline or a bad bearing    shows elsewhere."""    cap = bench.ripple_capture(RIPPLE_SPEED_RPM, RIPPLE_LOAD_NM)    torque = np.array(cap["torque_nm"])    ripple = torque - torque.mean()    spectrum = np.abs(np.fft.rfft(ripple))    order = int(np.argmax(spectrum[1:]) + 1)  # cycles per output revolution    measurements.ripple.x_axis = cap["angle_deg"]    measurements.ripple.y_axis.torque = cap["torque_nm"]    measurements.ripple.y_axis.torque.aggregations.pp_pct_rated = float(100.0 * (torque.max() - torque.min()) / RATED_TORQUE_NM)    measurements.ripple.y_axis.torque.aggregations.dominant_order = order    log.info(f"Ripple {100.0 * (torque.max() - torque.min()) / RATED_TORQUE_NM:.2f} % of rated p-p, dominant order {order} (2 x ratio = {2 * RATIO})")

Left, torque over the first 60° of the revolution oscillating between 29.8 and 30.3 Nm, 1.06 % of rated peak-to-peak. Right, the order spectrum with a dominant line at order 200 and a smaller one at order 100.

The mock's ripple: 1.06 % of rated peak-to-peak with the wave generator at order 200 and the input's once-per-revolution at order 100. A bearing defect shows at the bearing's ball-pass order, a damaged flexspline tooth at the tooth count; both move the dominant order and fail the ==.

Park

The teardown phase unloads the dyno, disables the drive and reads the housing temperature, so a hot actuator is not handed to the next station:

phases/park.py
def park(measurements, bench, log):    """Teardown: dyno unloaded, drive disabled, housing temperature read    so a hot actuator is not handed to the next station."""    bench.unload()    bench.disable()    measurements.temperature_end_c = bench.temperature_c()    log.info(f"Parked, housing at {measurements.temperature_end_c} C")

Mock Plug

ActuatorBench stands in for the dyno, the transducer, the power analyzer and the drive because the readings depend on each other and plugs run in separate processes. It synthesizes a motor Kt of 0.105 Nm/A ± 0.6 %, a locked-rotor reducer efficiency of 0.86 with a 1.6 Nm friction knee, an efficiency of 0.80 at rated falling quadratically toward light load and linearly above rated speed, a no-load torque that settles from 0.19 to 0.11 Nm with a 3 minute time constant, and a ripple of 1.4 % dominated by the wave generator's second harmonic with the input's first harmonic at a quarter of it. Every method returns plain Python types because plug calls cross a JSON boundary; a measurement read back from measurements.<key> returns a proxy, so the phases keep locals for their log lines.

On a real bench, the class speaks the drive's CoE objects through pysoem or a Speedgoat model, SCPI to the power analyzer, and reads the transducer through the DAQ with its angle channel; set TIME_SCALE = 1.0 and the run-in timeout to match the reducer maker's specification. Add a backlash and stiffness phase from the companion template when the same bench carries an output-side reference encoder. The phases, measurements and limits stay the same.

Run your first test in minutes