Reaction Wheel Acceptance Test

Reaction wheel acceptance with TofuPilot Framework: wear-in drag, static and dynamic unbalance, bus power, coast-down friction and exported force sweep.

TofuPilotAcceptance TestPythonTofuPilot FrameworkGitHub
Reaction wheel mounted on a test base connected to an instrument
Run this procedure.

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

Open the source on GitHub ↗

Introduction

Reaction Wheel Acceptance Overview

A reaction wheel is a flywheel on a pair of ball bearings, driven by a brushless motor, and it is the one mechanism on a small satellite that runs continuously for the whole mission. Every unit that leaves the vendor's line is accepted on the same set of numbers: how much the bearings drag once they have settled, how well the rotor is balanced, how much bus power it draws at speed, and how much force and torque it exports into the structure while it spins. The last one is the micro-vibration budget of the spacecraft; a wheel that shakes the optical bench at 100 Hz is a wheel the payload cannot use, whatever its momentum rating says.

Close-up of a reaction wheel can with the lid removed, the flywheel rim visible inside, one connector on the side

The unit under test with its lid off: a rim-heavy flywheel on two bearings in a sealed can, one connector for power and telemetry. Everything the acceptance test measures is a property of the rotor and the two bearings.

The numbers are small. A 0.05 Nms-class wheel draws a few watts, its bearing drag is a few tenths of a milli-newton-metre, and its residual unbalance is a fraction of a gram-millimetre. Blue Canyon publishes a static unbalance under 1.2 g·mm and a dynamic unbalance under 20 g·mm² for its small wheels and ships the measured pair per serial (see Blue Canyon's reaction wheel data); NewSpace Systems quotes 4 W at 3000 rpm and 6 W at 6500 rpm steady state on a comparable unit. The force the unbalance exports rises with the square of the wheel speed, so a rotor at 0.35 g·mm pushes about 140 mN on the mounting plate at 6000 rpm. Reading that on a dynamometer that resolves 3 mN takes an isolated table and a charge amplifier, and the sweep is the part of the test that tells a good bearing from a damaged one.

Test Purpose

The procedure records one acceptance record per wheel:

  • Firmware, bearing temperature at rest, and the serial's cumulative vibration log against the bearing's qualification budget, before anything spins
  • A one-hour wear-in through the speed profile with bearing drag and temperature logged, the settled drag validated
  • Static and dynamic unbalance from the dynamometer against the vendor's per-serial acceptance figures
  • Bus power at 3000 and 6000 rpm, then the bearing friction torque from a coast-down with the drive disabled
  • Exported force over a 500 to 6000 rpm sweep, validated against an absolute ceiling and against the speed-squared law a balanced rotor follows

Peak exported radial force in mN against wheel speed from 500 to 6000 rpm, green dots rising along a parabola from 6 to 173 mN, an orange dashed speed-squared fit under them, a red dashed 200 mN limit line at the top.

The mock wheel on the dynamometer: 173 mN at 6000 rpm against a 200 mN ceiling, and 5.9 mN worst deviation from the speed-squared law fitted to the sweep. A rotor that is simply out of balance moves the whole parabola up; a bearing with a damaged race adds a ball-pass line that does not scale with speed squared and shows as a bump the deviation aggregation catches.

The framework mechanics on show are a per-serial consumable budget validated in setup: and written to the unit metadata, a progress component on a time-scaled long phase with a timeout, two curves in one measurement each with its own aggregation and limit, a physical constant derived from a curve, a deviation-from-model aggregation next to an absolute one, and a teardown: that stops the wheel whatever happened.

Equipment & Setup

To accept reaction wheels on a production bench, the following are required:

  • A multicomponent dynamometer under the wheel's mounting plate, with a charge amplifier and a DAQ that covers 3 to 300 Hz
  • An isolated table that keeps the building out of the force measurement
  • A bus power analyzer on the 28 V supply and the wheel's own telemetry link for speed, current and bearing temperature
  • The Device Under Test (DUT): a 0.05 Nms-class reaction wheel, ±6000 rpm, 10 mNm
  • A TofuPilot Framework procedure to sequence the wear-in, the balance reading, the power and coast-down, and the sweep
  • The TofuPilot Dashboard to keep the vibration-exposure log per serial and trend drag, unbalance and exported force across the lot

Hardware Components

Dynamometer

The Kistler 9119AA1 is the reference for wheel micro-vibration: a three-component force plate with a threshold under 2 mN on Fx and Fy, three moment channels, and a natural frequency high enough that the 3 to 300 Hz band of interest sits well below it. A 5070A charge amplifier conditions the six piezo channels and the DAQ samples at 3200 S/s. The same plate reads the once-per-rev force and moment at balancing speed, which is where the static and dynamic unbalance come from, and the broadband force during the sweep. Anything that resolves a few mN at 100 Hz on a stiff plate does the job; what does not work is an accelerometer on the housing, because it reads the wheel's own response rather than what the wheel exports.

Reaction wheel acceptance bench: the wheel can bolted on a dynamometer plate on an isolation block, a bench instrument beside it

The bench: the wheel bolted flat on the dynamometer plate on its isolation block, the power analyzer and telemetry interface beside it. The plate's own mass and the block's stiffness set the noise floor of the whole test.

Wheel Telemetry and Bus Power

The wheel reports speed, motor current and bearing temperature over its serial link at a few hertz; the procedure reads all three during the wear-in and at each step of the sweep. A Yokogawa WT-class analyzer on the 28 V bus gives the steady-state power at each speed, the number the spacecraft's power budget uses. Bearing temperature is the second output of the wear-in: a bearing whose lubricant is distributed runs cooler, and a bearing that climbs past the limit during an hour at 6000 rpm is not going to survive a mission.

Wear-In and the Vibration Log

SMC-S-016 lists wear-in as a required acceptance test for moving mechanical assemblies, and a wheel's bearings need the hour through the speed profile before the drag, the power and the coast-down mean anything. The second bookkeeping item is the cumulative vibration exposure of the bearings: the wheel's ICD caps how many seconds of acceptance vibration the bearings may see on the ground so that ground plus launch stays under what the bearing was qualified to. The procedure reads that log per serial in setup and refuses to proceed on a wheel that has already spent its allowance. ECSS-E-ST-10-03C leaves the micro-vibration characterisation to the customer's requirements, so the exported-force limits here are the wheel's own, not a standard's.

Where the Limits Come From

The unbalance limits are the vendor's published acceptance figures for this wheel class: 1.2 g·mm static and 20 g·mm² dynamic. The settled drag limit of 0.30 mNm, the 0.35 mNm friction torque from the coast-down, and the 4.5 W and 7.5 W power ceilings sit above the vendor's steady-state curve with margin for a warm bench. The 200 mN exported-force ceiling is what a rotor at the unbalance limit pushes at full speed plus the bearing's contribution, and the 15 mN deviation limit is about five times the dynamometer's resolution. A customer sets the exported-force numbers from the spacecraft's micro-vibration budget, then tightens the deviation limit from the distribution the dashboard shows after a few dozen wheels.

Test Procedure

Overview

The procedure maps the acceptance test onto the framework's three stages. Identification and the vibration budget live in setup: so a wheel that fails the gate never spins. Stopping the wheel and reading the bearing temperature live in teardown: so the bench is safe to unload whether the test passed or not.

  1. Setup: firmware, bearing temperature at rest, vibration log against the budget.
  2. Main: one-hour wear-in through 1000, 3000, +6000 and -6000 rpm, drag and temperature logged.
  3. Main: static and dynamic unbalance from the dynamometer.
  4. Main: bus power at 3000 and 6000 rpm, coast-down from 6000 rpm, friction torque from the deceleration.
  5. Main: exported force at each speed from 500 to 6000 rpm, checked against the speed-squared law.
  6. Teardown: wheel stopped, bearing temperature read.

Every metric validates against limits declared in procedure.yaml, and results stream to TofuPilot for per-serial trending across the lot.

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
balance.py
power_and_friction.py
exported_force.py
stop.py
plugs
wheel_bench.py
utils
recipe.py
pyproject.toml
README.md

You can find the full source on GitHub. The WheelBench plug is a mock of the wheel's telemetry link, the power analyzer and the dynamometer together, synthesizing a healthy wheel with 0.35 g·mm of static unbalance, bearings that settle from 0.31 to 0.19 mNm during the wear-in, and an exported force that follows the speed-squared law with the bearing's ball-pass harmonic on top, so the procedure runs end-to-end without a wheel or a dynamometer connected.

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 · 171 lines
procedure.yaml
name: Reaction Wheel Acceptance Testversion: 0.1.0description: Acceptance test of a smallsat reaction wheel. Telemetry and vibration-exposure log in setup, time-scaled one-hour wear-in with bearing drag and temperature, static and dynamic unbalance on the dynamometer, bus power at two speeds and bearing friction from a coast-down, exported force over a speed sweep against the speed-squared law.unit:  auto_identify: true  serial_number:    description: "Scan the wheel's housing label"    placeholder: "RW-050-0000"    pattern: "^RW-050-\\d{4}$"    default_value: "RW-050-0312"  part_number:    default_value: "RW-050-28V"  batch_number:    default_value: "LOT-2026-09"plugs:  - name: Wheel Bench    description: "Wheel telemetry over RS-422, bus power analyzer, Kistler dynamometer under the mounting plate (mock, one plug per bench)"    python: plugs.wheel_bench:WheelBench    key: benchsetup:  - name: Identify    key: identify    python: phases.identify    measurements:      - name: Firmware Version        key: firmware_version        validators:          - {operator: matches, expected_value: "^1\\.\\d+\\.\\d+$"}      - name: Bearing Temperature Start        key: bearing_temp_start_c        unit: °C        validators:          - {operator: ">=", expected_value: 18.0}          - {operator: "<=", expected_value: 26.0}      - name: Vibration Log        key: vibration_log_s        unit: s        description: Cumulative acceptance vibration already on this serial; the bearing's qualification budget for acceptance plus launch is finite and the ICD says so.        validators:          - {operator: "<=", expected_value: 1800.0}main:  - name: Run In    key: run_in    python: phases.run_in    timeout: 2h    ui:      components:        - key: run_in_progress          type: progress          label: "Wear-in"          description: "60 min through 1000, 3000, +6000, -6000 rpm"          default_value: 0          max: 100    measurements:      - name: Wear-In        key: wear_in        title: Bearing drag torque and temperature through the wear-in        x_axis:          legend: Time          unit: min        y_axis:          - legend: Drag            key: drag            unit: mNm            aggregations:              - type: final_mnm                unit: mNm                validators:                  - {operator: "<=", expected_value: 0.30}          - legend: Bearing temperature            key: temperature            unit: °C            aggregations:              - type: max_c                unit: °C                validators:                  - {operator: "<=", expected_value: 45.0}  - name: Balance    key: balance    python: phases.balance    depends_on: [run_in]    measurements:      - name: Static Unbalance        key: static_unbalance_gmm        unit: g·mm        description: Vendor acceptance figure for this wheel class, shipped per serial.        validators:          - {operator: "<=", expected_value: 1.2}      - name: Dynamic Unbalance        key: dynamic_unbalance_gmm2        unit: g·mm²        validators:          - {operator: "<=", expected_value: 20.0}  - name: Power And Friction    key: power_and_friction    python: phases.power_and_friction    depends_on: [balance]    timeout: 15m    measurements:      - name: Power At 3000 rpm        key: power_3000_w        unit: W        validators:          - {operator: "<=", expected_value: 4.5}      - name: Power At 6000 rpm        key: power_6000_w        unit: W        validators:          - {operator: "<=", expected_value: 7.5}      - name: Coast-Down        key: coast_down        title: Speed after the drive is disabled at 6000 rpm        x_axis:          legend: Time          unit: s        y_axis:          - legend: Speed            key: speed            unit: rpm            aggregations:              - type: friction_mnm                unit: mNm                validators:                  - {operator: "<=", expected_value: 0.35}              - type: duration_s                unit: s                validators:                  - {operator: ">=", expected_value: 120.0}  - name: Exported Force    key: exported_force    python: phases.exported_force    depends_on: [power_and_friction]    timeout: 15m    measurements:      - name: EFT        key: eft        title: Peak exported radial force, 3 to 300 Hz, against wheel speed        x_axis:          legend: Speed          unit: rpm        y_axis:          - legend: Force            key: force            unit: mN            aggregations:              - type: max_mn                unit: mN                validators:                  - {operator: "<=", expected_value: 200.0}              - type: max_deviation_mn                unit: mN                validators:                  - {operator: "<=", expected_value: 15.0}teardown:  - name: Stop    key: stop    python: phases.stop    measurements:      - name: Bearing Temperature End        key: bearing_temp_end_c        unit: °C        validators:          - {operator: "<=", expected_value: 45.0}

Framework features to notice:

  1. A consumable budget in setup. vibration_log_s <= 1800 is not a property of the wheel's performance; it is how much of the bearing's ground allowance this serial has spent. It gates the run and lands on the unit metadata so the dashboard shows it per serial.
  2. Progress on a long phase. The wear-in is an hour on real hardware; the progress component and the timeout: 2h make the phase visible and bounded without any UI code.
  3. Two curves, two limits. wear_in records drag and bearing temperature against time; each curve carries its own aggregation, final_mnm on one and max_c on the other, with its own validator.
  4. A constant from a curve. coast_down.friction_mnm is the deceleration slope of the first 60 s times the rotor's inertia; the framework stores the curve and validates the derived number.
  5. Model deviation next to an absolute. eft.max_mn <= 200 bounds what the wheel exports; max_deviation_mn <= 15 bounds how far the sweep departs from the speed-squared law a balanced rotor obeys, which is what a bearing defect changes.

Identify

The setup phase reads the firmware and the bearing temperature at rest over the telemetry link, then reads the serial's cumulative vibration log and writes it to the unit metadata. A wheel that has already spent its acceptance vibration allowance fails here and never spins:

phases/identify.py
from utils.recipe import VIBRATION_LIFE_Sdef identify(measurements, bench, unit, log):    """Setup: telemetry link, firmware, bearing temperature at rest, and    the serial's cumulative vibration log against the bearing budget; a    wheel that has already spent its acceptance vibration allowance is    not re-vibrated whatever the sequence says."""    ident = bench.identify()    measurements.firmware_version = ident["firmware"]    measurements.bearing_temp_start_c = ident["bearing_temp_c"]    vib = bench.vibration_log_s()    measurements.vibration_log_s = vib    unit.metadata["vibration_exposure_s"] = vib    log.info(f"Wheel {unit.serial_number}: fw {ident['firmware']}, bearings at {ident['bearing_temp_c']} C, {vib:.0f} s of vibration logged against a {VIBRATION_LIFE_S:.0f} s budget")

Run In

One call returns the hour of wear-in as three lists; the phase converts time to minutes, records drag and bearing temperature as two curves of one measurement, and computes the settled drag as the mean of the last five minutes:

phases/run_in.py · 21 lines
phases/run_in.py
import numpy as npfrom utils.recipe import RUN_IN_MINUTES, RUN_IN_SPEEDS_RPMdef run_in(measurements, bench, ui, log):    """Wear-in per SMC-S-016: the wheel through its speed profile for an    hour, bearing drag and temperature logged. The drag at the end is    the number the coast-down and the power test are judged against."""    cap = bench.run_in(RUN_IN_SPEEDS_RPM, RUN_IN_MINUTES)    ui.run_in_progress = 100    drag = np.array(cap["drag_mnm"])    t = np.array(cap["time_s"])    final = float(drag[t >= t[-1] - 300.0].mean())    measurements.wear_in.x_axis = (t / 60.0).round(2).tolist()    measurements.wear_in.y_axis.drag = cap["drag_mnm"]    measurements.wear_in.y_axis.drag.aggregations.final_mnm = final    measurements.wear_in.y_axis.temperature = cap["bearing_temp_c"]    measurements.wear_in.y_axis.temperature.aggregations.max_c = float(max(cap["bearing_temp_c"]))    log.info(f"Wear-in {RUN_IN_MINUTES:.0f} min: drag {drag[0]:.3f} -> {final:.3f} mNm, bearings to {max(cap['bearing_temp_c']):.1f} C")

Balance

The dynamometer at a fixed balancing speed gives the once-per-rev force and moment, which are the static and dynamic unbalance. The two scalars go straight against the vendor's acceptance figures:

phases/balance.py
def balance(measurements, bench, log):    """Static and dynamic unbalance from the dynamometer at balancing    speed. The vendor ships these two numbers per serial; the limits are    the vendor's own acceptance figures."""    bal = bench.balance_measurement()    measurements.static_unbalance_gmm = bal["static_gmm"]    measurements.dynamic_unbalance_gmm2 = bal["dynamic_gmm2"]    log.info(f"Static {bal['static_gmm']:.2f} g-mm, dynamic {bal['dynamic_gmm2']:.1f} g-mm2")

Power And Friction

Bus power at 3000 and 6000 rpm from the analyzer, then the drive is disabled at 6000 rpm and the speed logged until 500 rpm. The slope of the first 60 s times the rotor's inertia is the bearing friction torque at high speed, the number that separates a dry or damaged bearing from a good one:

phases/power_and_friction.py · 31 lines
phases/power_and_friction.py
import numpy as npfrom utils.recipe import COAST_DOWN_FROM_RPM, MOMENTUM_NMS_AT_MAX, MAX_SPEED_RPM, POWER_W_MAXdef power_and_friction(measurements, bench, log):    """Steady-state bus power at two speeds, then a coast-down from full    speed with the drive disabled: the deceleration times the inertia is    the bearing friction torque, the number that tells a dry or a    damaged bearing from a good one."""    for rpm in sorted(POWER_W_MAX):        bench.spin_to(rpm)        p = bench.steady_power_w(rpm)        setattr(measurements, f"power_{int(rpm)}_w", p)        log.info(f"{rpm:.0f} rpm: {p:.2f} W on the bus")    bench.spin_to(COAST_DOWN_FROM_RPM)    coast = bench.coast_down(COAST_DOWN_FROM_RPM)    t = np.array(coast["time_s"])    w = np.array(coast["speed_rpm"]) * 2 * np.pi / 60.0    inertia = MOMENTUM_NMS_AT_MAX / (MAX_SPEED_RPM * 2 * np.pi / 60.0)    # Friction torque at high speed from the first 60 s of the coast-down.    m = t <= 60.0    slope, _ = np.polyfit(t[m], w[m], 1)    friction_mnm = float(-slope * inertia * 1000.0)    measurements.coast_down.x_axis = coast["time_s"]    measurements.coast_down.y_axis.speed = coast["speed_rpm"]    measurements.coast_down.y_axis.speed.aggregations.friction_mnm = friction_mnm    measurements.coast_down.y_axis.speed.aggregations.duration_s = float(t[-1])    log.info(f"Coast-down from {COAST_DOWN_FROM_RPM:.0f} rpm: {t[-1]:.0f} s to 500 rpm, friction {friction_mnm:.3f} mNm at high speed")

Left, bearing drag torque in green falling from 0.31 to 0.20 mNm over 60 minutes of wear-in under a red dashed 0.30 mNm limit with the last five minutes shaded, the bearing temperature as an orange dotted line rising from 21 to 32 °C on the right axis. Right, the coast-down speed in green falling from 6000 to 500 rpm over 181 s with an orange dashed line fitted to the first 60 s.

The mock wheel's hour: drag settles from 0.311 to 0.195 mNm as the lubricant distributes, bearings warm to 31.5 °C, and the coast-down from 6000 rpm takes 181 s with a friction torque of 0.290 mNm from the first minute. The bus draws 2.53 W at 3000 rpm and 2.90 W at 6000 rpm. A wheel whose drag does not settle, or whose coast-down is short, has a bearing problem the sweep will then locate.

Exported Force

At each speed of the sweep the phase reads the peak exported radial force in the 3 to 300 Hz band, fits the speed-squared law to the whole sweep, and reports the maximum and the worst deviation from the fit:

phases/exported_force.py · 25 lines
phases/exported_force.py
import numpy as npfrom utils.recipe import EFT_SWEEP_RPMdef exported_force(measurements, bench, log):    """Exported force on the dynamometer at each speed of the sweep, 3 to    300 Hz. A balanced rotor's force rises with speed squared; a bearing    defect adds a line that does not, and shows as a bump in the curve."""    force = []    for rpm in EFT_SWEEP_RPM:        bench.spin_to(rpm)        force.append(bench.exported_force_mn(rpm))    bench.stop()    force = np.array(force)    rpm = np.array(EFT_SWEEP_RPM, dtype=float)    # Fit the unbalance law and report the worst deviation from it.    k, _ = np.polyfit(rpm ** 2, force, 1)    resid = force - (k * rpm ** 2 + force[0] - k * rpm[0] ** 2)    measurements.eft.x_axis = EFT_SWEEP_RPM    measurements.eft.y_axis.force = force.tolist()    measurements.eft.y_axis.force.aggregations.max_mn = float(force.max())    measurements.eft.y_axis.force.aggregations.max_deviation_mn = float(np.abs(resid).max())    log.info(f"Exported force {force.min():.1f}..{force.max():.1f} mN over {rpm.min():.0f}..{rpm.max():.0f} rpm, worst deviation from the speed-squared law {np.abs(resid).max():.1f} mN")

Mock Plug

WheelBench stands in for the wheel's telemetry link, the bus power analyzer and the dynamometer. It draws a rotor with 0.35 g·mm of static and 9 g·mm² of dynamic unbalance, bearing drag that decays exponentially from 0.31 to 0.19 mNm with a 15-minute time constant while the bearings warm from 21 to 32 °C, bus power as the drag times the speed over the drive efficiency plus 2.4 W of electronics, a coast-down integrated at 1 Hz from the drag over the inertia, and an exported force that is the unbalance times the speed squared plus a small bearing term. The wear-in is time-scaled and returns in one call. 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 station, the class speaks the wheel's protocol over pyserial for speed, current and bearing temperature, SCPI to the power analyzer over pyvisa, and reads the six dynamometer channels through the charge amplifier and the DAQ with a 3 to 300 Hz band. Set TIME_SCALE = 1.0 in utils/recipe.py for the real hour of wear-in, keep the vibration-exposure log in the dashboard per serial and add every acceptance vibration run to it, and take the unbalance limits from the vendor's data sheet for the wheel class. The phases, measurements and limits stay the same.

Run your first test in minutes