Skip to content

Motor Balance and Vibration Signature

Capture drone motor vibration spectra across an RPM sweep to catch bearing defects, rotor imbalance, and misalignment at end-of-line QA.

TofuPilotQuality AssurancePythonTofuPilot FrameworkGitHub
Motor Balance and Vibration Signature

Introduction

Vibration in Drone Motors

Every BLDC motor on a drone produces vibration. Some is structural (rotor imbalance, bearing roughness), some is electromagnetic (cogging torque, commutation harmonics), and some is acoustic coupling from the frame itself. In flight, this vibration corrupts the IMU accelerometer readings that attitude estimation depends on: PX4's flight log analysis guidance treats anything above roughly 2-3 m/s² peak-to-peak on the accelerometer as strong vibration, and sustained levels beyond that cause attitude drift, position hold wander, and visual-inertial odometry failure. On gimballed cameras, residual vibration passes through the rolling shutter and renders "jello", a vertical wave artifact widely attributed to vibration passing through worn gimbal dampers (DJI advises replacing aged dampers because they "may affect shooting").

Cutaway of a drone brushless outrunner motor showing the bell, stator windings, magnet ring, shaft and the top and bottom ball bearings.

Imbalance lives in the bell and magnets, misalignment in the shaft, defect tones in the two bearings.

Continuous side-loading from imbalance also accelerates bearing race wear and damper fatigue over the airframe's life. End-of-line vibration testing catches the bad units before they ship and trends bearing supplier quality across the production batch.

Test Purpose

The procedure produces a per-motor vibration signature:

  • 1× rotational order amplitude: static or dynamic imbalance (loose magnet, lost balance dot, deformed bell)
  • 2× rotational order: misalignment, bent shaft
  • Bearing defect bands: non-synchronous peaks at the outer-race (BPFO) and inner-race (BPFI) defect frequencies
  • Broadband RMS across the sweep, and at hover RPM specifically

Comparing against a golden-reference fingerprint flags units with anomalous content. Production tightens the limits via TofuPilot's rolling 3σ analytics after the first 500-1000 units.

3D waterfall of the sweep spectra plotted against order and RPM: the 1×, 2×, BPFO and BPFI content forms constant-order ridges across the whole 2000-12000 RPM range.

Order waterfall of the mock sweep: rotor and bearing content stays on its order across the whole RPM range, which is what separates it from a fixed-frequency structural resonance.

Equipment & Setup

To implement motor vibration testing on a production line, the following are required:

  • A rigid steel motor fixture isolated from the bench, with an integrated tachometer pickup
  • A triaxial accelerometer mounted to the motor stator or fixture
  • A high-speed DAQ with anti-alias filtering
  • The Device Under Test (DUT): motor only, prop must be removed for safety
  • A programmable motor driver (same ESC + DShot as ESC characterization)
  • A TofuPilot Framework procedure to step RPM, capture spectra, extract order peaks, and flag anomalies
  • The TofuPilot Dashboard to log fingerprints and trend bearing supplier quality

Hardware Components

Accelerometer

The production reference is the PCB Piezotronics 356A32: a triaxial ICP/IEPE accelerometer, 100 mV/g, 1 Hz to 4 kHz band (±5%), 5 g titanium housing, sold on a quote basis. It is an industry standard for rotating-machinery diagnostics. For embedded in-airframe monitoring at lower cost, the Analog Devices ADXL355 (low-noise MEMS, 22.5 µg/√Hz on the ±2 g range, ±2/4/8 g selectable, ~$50) is the alternative.

Brushless motor bolted to a heavy steel fixture block with no propeller, a stud-mounted accelerometer on the stator and its cable running to the DAQ.

Stud-mounted accelerometer on the stator, motor bolted to the steel fixture, prop removed.

Mounting matters: in decreasing rigidity, stud, thin adhesive, beeswax, flat magnet, curved magnet. PCB's TN-5 mounting technote documents the bandwidth cost of each; a magnet mount can halve the usable frequency range. Wax is acceptable below ~1 kHz, magnet below ~2 kHz. For end-of-line testing requiring fast swap, a magnet-mounted sensor on a steel fixture is the practical compromise. For permanent reference benches, stud-mount.

DAQ

The NI-9234 C Series module (in a cDAQ chassis) is the reference: 4 channels IEPE, 24-bit, 51.2 kS/s/ch, software-selectable 2 mA IEPE current, automatic anti-alias filter tracking the sample rate. Dewesoft and Brüel & Kjær offer production-line analyzers with built-in order-tracking workflows. For higher channel counts (multiple motors in parallel), a cDAQ-9189 chassis with multiple NI-9234 modules scales linearly.

Motor Fixture

A machined steel block bolted to a vibration-isolated bench (concrete floor or vibration-isolation table). The motor mounts on a flange with a stud-mount accelerometer pickup on the stator. The fixture's natural modes must be above 5 kHz (verified by impact hammer test) so they don't contaminate the motor signature. Mass typically 5-10 kg.

Custom Firmware

ESC under bidirectional DShot, same as the throttle-to-RPM characterization test. The test station commands RPM directly via DShot frames and reads back eRPM telemetry as the speed reference. Because the sweep dwells at fixed RPM steps, a windowed FFT per step gives clean order peaks; continuous sweeps would need angular-domain order tracking instead.

Test Procedure

Overview

After mounting the motor (prop removed) and connecting accelerometer + DAQ + ESC, the procedure runs:

  1. Safety gate: the operator confirms prop removal on a switch; the run refuses to continue otherwise.
  2. Step RPM from 2000 to 12000 in 50 logarithmic steps, acquiring 200 ms at 10 kHz per step.
  3. Compute a Hann-windowed FFT per step and extract the 1×, 2×, BPFO, and BPFI peaks.
  4. Validate every metric against per-axis limits.
  5. Record the hover-RPM spectrum and the 1× trace as interactive charts, attach the metrics CSV.
  6. Stream results to TofuPilot for traceability and analytics.

This template exercises several framework capabilities on purpose: an operator UI safety gate bound to a boolean measurement, multi-dimensional measurements with per-axis aggregations, file attachments, multi-slot execution for testing four motor arms in parallel, and all four log levels.

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
safety_check.py
rpm_sweep.py
vibration_analysis.py
plugs
daq.py
utils
bearing.py
ui.json
pyproject.toml

You can find the full source on GitHub. The mock DAQ plug synthesizes captures in code, so the procedure runs end to end without hardware:

tofupilot run . --ui-values ui.json

ui.json pre-bakes the operator confirmation for headless runs; on a station the operator flips the switch instead.

The Procedure File

procedure.yaml declares the unit, the DAQ plug, three phases wired with depends_on, and every measurement with its limits. This is the exact file from the repository:

procedure.yaml
name: Motor Balance and Vibration Signatureversion: 0.1.0description: Per-motor vibration fingerprint across an RPM sweep with order extraction, bearing defect detection, and interactive spectrum charts.execution:  workers: 8  strategy: phase_first  # Multi-slot parallel testing: uncomment to test all four motor arms at  # once on a station (one dashboard run per slot). The one-shot CLI path  # (`tofupilot run`) does not support multi-slot yet, so the local template  # runs single-slot.  # slots:  #   - name: ARM1  #   - name: ARM2  #   - name: ARM3  #   - name: ARM4unit:  auto_identify: true  serial_number:    default_value: "SN-MOTOR-0001"  part_number:    default_value: "MOTOR-2207"plugs:  - name: DAQ    key: daq    description: Mock IEPE DAQ that synthesizes an accelerometer capture per RPM step.    python: plugs.daq:MockDaqmain:  - name: Safety Check    key: safety_check    python: phases.safety_check    ui:      requires_input: true      components:        - key: prop_removed          type: switch          label: "Propeller removed"          description: "Confirm the propeller is removed before the motor is energized"          bind: measurements.prop_removed    measurements:      - name: Prop Removed        key: prop_removed        description: Operator confirmation that no propeller is installed.        validators:          - operator: "=="            expected_value: true  - name: RPM Sweep    key: rpm_sweep    python: phases.rpm_sweep    depends_on: [safety_check]    measurements:      - name: Steps Acquired        key: steps_acquired        validators:          - operator: "=="            expected_value: 50  - name: Vibration Analysis    key: vibration_analysis    python: phases.vibration_analysis    depends_on: [rpm_sweep]    measurements:      - name: 1x Peak Amplitude        key: one_x_amplitude        unit: g        description: Rotational-order peak, dominated by rotor imbalance.        validators:          - operator: "<="            expected_value: 0.8      - name: 2x Peak Amplitude        key: two_x_amplitude        unit: g        description: Second-order peak, dominated by misalignment or a bent shaft.        validators:          - operator: "<="            expected_value: 0.4      - name: BPFO Amplitude        key: bpfo_amplitude        unit: g        description: Outer-race bearing defect band amplitude.        validators:          - operator: "<="            expected_value: 0.2      - name: BPFI Amplitude        key: bpfi_amplitude        unit: g        description: Inner-race bearing defect band amplitude.        validators:          - operator: "<="            expected_value: 0.2      - name: Hover Band RMS        key: hover_rms        unit: g        description: Broadband RMS in the 5000-7000 RPM hover band.        validators:          - operator: "<="            expected_value: 0.5      - name: Full Sweep RMS        key: sweep_rms        unit: g        validators:          - operator: "<="            expected_value: 1.5      - name: Hover Spectrum        key: hover_spectrum        title: Spectrum at hover RPM        description: Amplitude spectrum at the sweep step closest to 6000 RPM, rendered as an interactive chart by the dashboard.        x_axis:          legend: Frequency          unit: Hz        y_axis:          - legend: Amplitude            key: amplitude            unit: g      - name: 1x Order Trace        key: one_x_trace        title: 1x amplitude vs RPM        description: Rotational-order amplitude across the sweep. A flat trace is a balanced rotor; a rising trace tracks imbalance with speed.        x_axis:          legend: RPM          unit: rpm        y_axis:          - legend: 1x Amplitude            key: one_x            unit: g            aggregations:              - type: p2p                unit: g                validators:                  - operator: "<="                    expected_value: 0.5

The slots block is the multi-slot showcase: uncommented on a station engine, the same procedure tests all four motor arms in parallel, producing one dashboard run per slot. The one-shot CLI path does not support multi-slot yet, so the local template runs single-slot. The workers: 8 and strategy: phase_first settings control how many phases run concurrently and, on a multi-slot station, whether each phase completes across all slots before the next phase starts.

Where do the limits come from? The hover-band RMS limit (0.5 g ≈ 4.9 m/s²) is set so a motor passing the test contributes well under the 2-3 m/s² peak-to-peak accelerometer disturbance that PX4 flags as strong vibration once frame damping is accounted for. The order and bearing-band limits start as fractions of that budget, in the spirit of the zone classification that ISO 20816-1 (which replaced ISO 10816-1) applies to machine vibration severity, and are then tightened to the observed production 3σ once a few hundred units have run.

Safety Gate

The first phase is an operator UI gate. A switch component is bound to the prop_removed boolean measurement with bind, and requires_input: true forces a Continue click. The == true validator fails the phase if the operator does not confirm, so the motor is never energized with a prop installed:

phases/safety_check.py
def safety_check(log):    """Operator gate: the bound switch writes measurements.prop_removed.    The phase passes only if the operator confirms the propeller is removed;    the validator `== true` fails the phase otherwise and the run stops    before the motor is ever energized.    """    log.info("Waiting for operator confirmation that the propeller is removed")

No Python is needed to read the switch: the binding writes the operator's answer straight into the measurement, and the validator does the enforcement.

RPM Sweep

50 logarithmic RPM steps from 2000 to 12000 RPM, 200 ms at 10 kHz per step. Logarithmic spacing concentrates resolution where it matters: low-RPM bearing pre-failure indicators and high-RPM resonance. The sweep phase drives the DAQ plug and validates the step count:

phases/rpm_sweep.py
RPM_START = 2000RPM_END = 12000STEPS = 50def rpm_sweep(daq, measurements, log):    log.debug(f"Commanding sweep {RPM_START}-{RPM_END} RPM in {STEPS} log steps")    rpms = daq.acquire_sweep(RPM_START, RPM_END, STEPS)    if len(rpms) < STEPS:        log.warning(f"Sweep returned {len(rpms)} steps, expected {STEPS}")    measurements.steps_acquired = len(rpms)    log.info(f"Acquired {len(rpms)} captures from {rpms[0]} to {rpms[-1]} RPM")

RPM staircase of the sweep, 50 logarithmic steps from 2000 to 12000 RPM over about 60 s, with the 200 ms acquisition window at the end of every dwell highlighted.

Each dwell ends with a 200 ms capture; the logarithmic spacing packs more steps into the low-RPM range.

The plug caches each capture so the analysis phase can fetch it later. The mock synthesizes a realistic signal (1× imbalance, 2× misalignment, bearing tones, broadband noise). Critically, it injects the bearing tones at the same BPFO/BPFI frequencies the analysis searches, using the shared geometry in utils/bearing.py, so the detection path is genuinely exercised: raising the injected outer-race tone above 0.2 g makes the run fail on the bpfo_amplitude validator. Swap the class for one wrapping nidaqmx and your ESC driver to run on real hardware:

plugs/daq.py
import mathimport randomfrom utils.bearing import bearing_defect_frequenciesclass MockDaq:    """Simulated DAQ. Synthesizes one accelerometer capture per RPM step.    A real implementation would wrap nidaqmx and command the ESC over    bidirectional DShot; the interface below stays identical. The bearing    tones are injected at the same BPFO/BPFI frequencies the analysis    phase searches, from the shared geometry in utils/bearing.py.    """    SAMPLE_RATE_HZ = 10_000    CAPTURE_S = 0.2    def __init__(self):        self._captures = {}        random.seed(2207)        print("Mock DAQ initialized (10 kHz, 200 ms per step)")    def acquire_sweep(self, rpm_start, rpm_end, steps):        """Run the RPM sweep and cache one capture per step. Returns step RPMs."""        rpms = [            rpm_start * (rpm_end / rpm_start) ** (i / (steps - 1))            for i in range(steps)        ]        for rpm in rpms:            self._captures[round(rpm)] = self._synthesize(rpm)        return [round(r) for r in rpms]    def get_capture(self, rpm):        """Return the cached capture for one RPM step as a list of floats."""        return self._captures[rpm]    def _synthesize(self, rpm):        f_r = rpm / 60.0        n = int(self.SAMPLE_RATE_HZ * self.CAPTURE_S)        dt = 1.0 / self.SAMPLE_RATE_HZ        defects = bearing_defect_frequencies(f_r)        samples = []        for i in range(n):            t = i * dt            v = 0.38 * math.sin(2 * math.pi * f_r * t)                  # 1x imbalance            v += 0.14 * math.sin(2 * math.pi * 2 * f_r * t)             # 2x misalignment            v += 0.12 * math.sin(2 * math.pi * defects["bpfo"] * t)     # outer race tone            v += 0.06 * math.sin(2 * math.pi * defects["bpfi"] * t)     # inner race tone            v += random.gauss(0.0, 0.02)                        # broadband noise            samples.append(v)        return samples

Bearing Defect Frequencies

Per the standard rolling-element formulas (see for example Timken's bearing frequency guide), with N balls, ball diameter d, pitch diameter D, contact angle φ, shaft speed f_r:

  • BPFO = (N/2) · f_r · (1 − (d/D) cos φ)
  • BPFI = (N/2) · f_r · (1 + (d/D) cos φ)
  • BSF = (D/(2d)) · f_r · (1 − ((d/D) cos φ)²)
  • FTF = (1/2) · f_r · (1 − (d/D) cos φ)

The template validates the two highest-signal bands, BPFO and BPFI; the utility also computes BSF and FTF for production lines that trend cage and ball defects. The geometry constants live in one place, imported by both the mock and the analysis, so injected tones and search windows can never drift apart:

utils/bearing.py
# Bearing geometry for the motor's 7-ball deep-groove bearing (per motor# model, loaded from a spec in production). The mock DAQ and the analysis# phase both import these so the injected tones and the search windows can# never drift apart.N_BALLS = 7D_OVER_DP = 0.28def bearing_defect_frequencies(f_r, n_balls=N_BALLS, d_over_dp=D_OVER_DP, cos_phi=1.0):    """Rolling-element defect frequencies for shaft speed f_r in Hz."""    ratio = d_over_dp * cos_phi    return {        "bpfo": (n_balls / 2.0) * f_r * (1.0 - ratio),        "bpfi": (n_balls / 2.0) * f_r * (1.0 + ratio),        "bsf": (1.0 / (2.0 * d_over_dp)) * f_r * (1.0 - ratio**2),        "ftf": 0.5 * f_r * (1.0 - ratio),    }

With this 7-ball, d/D = 0.28 geometry at 6000 RPM (f_r = 100 Hz): BPFO = 252 Hz, BPFI = 448 Hz.

Amplitude spectrum at the 5990 RPM hover step with the annotated peaks: 1× at 100 Hz and 0.38 g, 2× at 200 Hz and 0.14 g, BPFO at 252 Hz and 0.11 g, BPFI at 447 Hz and 0.05 g, broadband floor near 0.02 g.

Hover-step spectrum of the mock capture: every peak the analysis validates, each against its limit.

Vibration Analysis

The analysis phase computes a windowed FFT per step, extracts the order and bearing-band peaks, fills the two interactive charts, and attaches the per-step metrics as a CSV. It also reads the previous phase's result through previous-results injection: naming a parameter rpm_sweep gives read access to that phase's measurements:

phases/vibration_analysis.py
import osimport tempfileimport numpy as npfrom utils.bearing import bearing_defect_frequenciesSAMPLE_RATE = 10_000HOVER_BAND = (5000, 7000)def _peak_near(freqs, spectrum, target_hz, tolerance_bins=3):    idx = int(np.argmin(np.abs(freqs - target_hz)))    lo = max(0, idx - tolerance_bins)    hi = min(len(spectrum), idx + tolerance_bins + 1)    return float(np.max(spectrum[lo:hi]))def vibration_analysis(daq, rpm_sweep, measurements, attach, log):    steps = int(rpm_sweep.steps_acquired)    log.debug(f"Analyzing {steps} captures from the sweep phase")    rpms = [        2000 * (12000 / 2000) ** (i / (steps - 1)) for i in range(steps)    ]    rpms = [round(r) for r in rpms]    spectra = []    one_x, two_x, bpfo_amp, bpfi_amp, rms_all = [], [], [], [], []    freqs = None    for rpm in rpms:        samples = np.asarray(daq.get_capture(rpm))        window = np.hanning(len(samples))        spectrum = np.abs(np.fft.rfft(samples * window)) * 2.0 / np.sum(window)        freqs = np.fft.rfftfreq(len(samples), 1.0 / SAMPLE_RATE)        spectra.append(spectrum)        f_r = rpm / 60.0        defects = bearing_defect_frequencies(f_r)        one_x.append(_peak_near(freqs, spectrum, f_r))        two_x.append(_peak_near(freqs, spectrum, 2 * f_r))        bpfo_amp.append(_peak_near(freqs, spectrum, defects["bpfo"]))        bpfi_amp.append(_peak_near(freqs, spectrum, defects["bpfi"]))        rms_all.append(float(np.sqrt(np.mean(samples**2))))    hover = [r for r, rpm in zip(rms_all, rpms) if HOVER_BAND[0] <= rpm <= HOVER_BAND[1]]    if not hover:        log.warning("No sweep step fell inside the hover band, using nearest step")        hover = [rms_all[int(np.argmin(np.abs(np.asarray(rpms) - 6000)))]]    measurements.one_x_amplitude = max(one_x)    measurements.two_x_amplitude = max(two_x)    measurements.bpfo_amplitude = max(bpfo_amp)    measurements.bpfi_amplitude = max(bpfi_amp)    measurements.hover_rms = max(hover)    measurements.sweep_rms = max(rms_all)    if max(bpfo_amp) > 0.18:        log.error(            f"BPFO amplitude {max(bpfo_amp):.3f} g at the 0.2 g limit, "            "suspect a damaged outer race"        )    elif max(bpfo_amp) > 0.1:        log.warning(            f"BPFO amplitude {max(bpfo_amp):.3f} g above the 0.1 g watch level, "            "trend the bearing supplier lot"        )    hover_idx = int(np.argmin(np.abs(np.asarray(rpms) - 6000)))    hover_spec = spectra[hover_idx]    keep = freqs <= 1500.0    measurements.hover_spectrum.x_axis = freqs[keep].tolist()    measurements.hover_spectrum.y_axis.amplitude = hover_spec[keep].tolist()    measurements.one_x_trace.x_axis = [float(r) for r in rpms]    measurements.one_x_trace.y_axis.one_x = one_x    aggs = measurements.one_x_trace.y_axis.one_x.aggregations    aggs.p2p = float(max(one_x) - min(one_x))    csv_path = os.path.join(tempfile.gettempdir(), "sweep_metrics.csv")    table = np.column_stack([rpms, one_x, two_x, bpfo_amp, bpfi_amp, rms_all])    np.savetxt(        csv_path,        table,        delimiter=",",        header="rpm,one_x_g,two_x_g,bpfo_g,bpfi_g,rms_g",        comments="",        fmt="%.6f",    )    attach.file(csv_path, "sweep_metrics.csv")    log.info("Sweep metrics CSV attached, analysis complete")

The log levels are used deliberately: debug for internals, info for milestones, warning when the BPFO amplitude crosses a watch level below the limit, and error when it reaches the limit itself. The mock's injected outer-race tone sits at 0.12 g, above the 0.1 g watch level, so the warning fires on every reference run and the run still passes.

Interactive Charts Instead of Plot Files

hover_spectrum and one_x_trace are multi-dimensional measurements. The dashboard renders them as interactive charts directly from the declared axes, so no plotting library is needed and no PNG is generated. The 1× trace carries a p2p aggregation with its own validator: a rotor whose 1× amplitude swings more than 0.5 g across the sweep fails even if every individual point is in spec. The raw per-step metrics attach as sweep_metrics.csv via attach.file() for offline analysis.

The headless input file used by the CI run is one line:

ui.json
{"safety_check": {"prop_removed": true}}

Run your first test in minutes