Random Vibration Acceptance Test

Random vibration acceptance with TofuPilot Framework: sine signature, GEVS 10 grms random run judged on the control channel, first-mode shift, three axes.

TofuPilotEnvironmental TestPythonTofuPilot FrameworkGitHub
Vibration shaker with a mounted test fixture and controller
Run this procedure.

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

Open the source on GitHub ↗

Introduction

Random Vibration Acceptance Overview

Every unit that flies is shaken before it ships. The random vibration acceptance test puts the unit on a shaker, drives the fixture to a specified spectrum for a minute in each axis, and reads two things: whether the shaker actually delivered the spectrum, and whether the unit is the same unit afterwards. The first is judged on the control accelerometer against the reference profile. The second is judged with a low-level sine sweep before and after each run, the resonance signature; a bolt that lost preload or a bracket that cracked moves the first mode, and the sweep is how the test sees it without opening the box.

Close-up of an electronic unit bolted on a fixture plate with a control accelerometer on the plate and a response accelerometer on the unit

The unit on its fixture: four bolts at torque, the control accelerometer on the plate next to the unit and the response accelerometer on the unit's lid. The control channel closes the loop with the controller; the response channel is what the unit felt.

The levels are written down. NASA's GEVS gives generalized random vibration levels for components of 22.7 kg or less: 10.0 grms for acceptance, 14.1 grms for qualification and protoflight, one minute per axis, a profile that rises at 6 dB/octave from 0.013 g²/Hz at 20 Hz to 0.08 g²/Hz at 50 Hz, holds to 800 Hz and falls at 6 dB/octave to 2000 Hz (see GSFC-STD-7000A). SMC-S-016 sets the acceptance workmanship minimum at 6.9 grms, not the 6.8 that circulates. What the standards do not give is a number for the signature comparison: the 5 % shift in the first mode that most test plans use as the flag is customary, and this template treats it as a recipe constant, not a clause.

Test Purpose

The procedure records one three-axis acceptance record per unit:

  • Fixture and instrumentation gate: control accelerometer sensitivity against its calibration, unit mass, the four bracket bolts at torque
  • Per axis, a low-level sine signature: first mode above the launcher's floor, amplification bounded
  • Per axis, the random run at the acceptance level: control grms within its window, every spectral line within tolerance of the reference, a full minute at level, the response on the unit recorded and bounded
  • Per axis, the signature again, with the first-mode shift and the amplification change validated against the pre-run curve

Three log-log panels, X, Y and Z, each showing the transmissibility of the unit over 5 to 2000 Hz before the random run in gray and after in dashed green, first modes at 210, 260 and 340 Hz with a second mode above, red dashed lines at the 100 Hz first-mode floor and the Q of 30 ceiling.

The mock unit's signatures: first modes at 210, 260 and 340 Hz with amplifications of 12 to 16, and after each random run a shift of 0.5 to 1 % as the bracket joint settles, against the 5 % flag. A bolt that backed off shifts the mode by several percent and drops the amplification; a cracked bracket adds a mode.

The framework mechanics on show are one Python function shared by three axis phases that address per-axis measurement keys, three curves in one measurement with aggregations on two of them, a window on an overall level next to a per-line tolerance, a before-versus-after comparison expressed as aggregations of the post curve, peak picking below the sweep's line spacing, and a fixture gate in setup: that keeps the shaker off until the sensor, the mass and the torques are right.

Equipment & Setup

To run random vibration acceptance on electronic units, the following are required:

  • An electrodynamic shaker with a head expander for the vertical axis and a slip table for the two lateral ones
  • A vibration controller with at least one control and one response channel, sine and random modes, and an API the procedure can drive
  • IEPE accelerometers with TEDS or a calibration sheet, one on the fixture for control and one on the unit for response
  • The Device Under Test (DUT): a smallsat onboard computer, 1.2 kg, on its four-bolt bracket
  • A TofuPilot Framework procedure to sequence the gate, the three axes and the signature comparisons
  • The TofuPilot Dashboard to keep the signature per serial across the lot and the vibration-exposure log the bearings and the crystals care about

Hardware Components

Shaker and Controller

A shaker of the LDS V8 or Unholtz-Dickie class with a slip table covers a unit this size with margin. The controller does the closed-loop work: it drives the shaker so the control accelerometer's spectrum matches the reference, and it reports the control and response ASDs averaged over the minute at level, the overall grms and the elapsed time at full level. m+p VibControl, Crystal Instruments Spider and Siemens LMS all expose this over an API; the procedure asks for the run and reads the result, it does not close the loop itself.

Random vibration bench: an electrodynamic shaker with the unit bolted on its head plate, a vibration controller instrument beside it

The bench in the vertical configuration: the unit on the head plate, the controller beside the shaker. The lateral axes are the same setup on the slip table.

Accelerometers and the Setup Gate

The control accelerometer's sensitivity scales the entire test. A 10 mV/g sensor read as 9.5 mV/g drives the unit 5 % harder than the reference. The setup phase reads the sensitivity from the TEDS or the calibration sheet and compares it to the recipe; it also reads the unit's mass, because the reference profile is for a mass class, and the torque log of the four bracket bolts, because a bolt below torque is the most common reason a signature shifts and the test should not start on one.

The Signature

The low-level sine sweep, 0.5 g from 5 to 2000 Hz at 2 octaves per minute, gives the transmissibility of the unit on its fixture: response over control against frequency. The first peak above the noise is the first mode; its height is the amplification Q. The procedure picks the peak with a parabolic fit in log-log around the highest line, because the sweep's 1/24-octave lines are 3 % apart and the shift criterion is 5 %. The same sweep after the random run, on the same fixture with nothing touched, is the comparison the test is named for.

Where the Limits Come From

The reference profile and the 10.0 grms are GEVS acceptance for components of 22.7 kg or less; a protoflight programme sets RANDOM_GRMS_TARGET to 14.1 and scales the profile. The 9.0 to 11.0 grms window is ±10 % on the overall level and the ±1.5 dB per line is the controller tolerance most test plans use. The 100 Hz first-mode floor is the launcher's secondary-structure requirement from the ICD. The Q ceiling of 30 and the 40 grms response ceiling are the unit's own qualification. The 5 % first-mode shift and the 20 % amplification change are the customary flags, and a customer tightens them from the distribution the dashboard shows after a few dozen units.

Test Procedure

Overview

The procedure maps the acceptance test onto the framework's three stages. The instrumentation and fixture gate lives in setup: so the shaker never runs on a wrong sensor or a loose bolt. The three axes run as three main phases chained with depends_on, because the fixture is rotated between them. Recording the exposure lives in teardown: so the serial's log is updated whatever happened.

  1. Setup: controller, control accelerometer sensitivity, unit mass, bracket torques.
  2. Main, X: signature, random run at 10.0 grms for 60 s, signature.
  3. Main, Y: same.
  4. Main, Z: same.
  5. Teardown: shaker off, exposure added to the serial's record.

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
vibration.py
axis_x.py
axis_y.py
axis_z.py
unmount.py
plugs
shaker_bench.py
utils
recipe.py
pyproject.toml
README.md

You can find the full source on GitHub. The ShakerBench plug is a mock of the controller, the shaker and the accelerometers together, synthesizing a healthy unit with a first mode at 210, 260 and 340 Hz in the three axes, a controller that holds the reference within a fraction of a decibel, and a bracket joint that settles under one percent per run, so the procedure runs end-to-end without a shaker or a unit 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. The three axis phases are identical apart from their keys:

procedure.yaml · 300 lines
procedure.yaml
name: Random Vibration Acceptance Testversion: 0.1.0description: Random vibration acceptance of a smallsat electronic unit, three axes. Fixture and instrumentation checked in setup, then per axis a low-level sine signature, the GEVS acceptance random run at 10.0 grms for one minute judged on the control channel against the reference, and the signature again with the first-mode shift validated.unit:  auto_identify: true  serial_number:    description: "Scan the unit label"    placeholder: "OBC-1U-0000"    pattern: "^OBC-1U-\\d{4}$"    default_value: "OBC-1U-0231"  part_number:    default_value: "OBC-1U-B"  batch_number:    default_value: "LOT-2026-09"plugs:  - name: Shaker Bench    description: "Vibration controller, shaker and slip table, control and response accelerometers, bolt torque log (mock, one plug per bench)"    python: plugs.shaker_bench:ShakerBench    key: benchsetup:  - name: Identify    key: identify    python: phases.identify    measurements:      - name: Controller        key: controller_id        validators:          - {operator: matches, expected_value: "^VC-\\d$"}      - name: Control Accelerometer Error        key: control_accel_error_pct        unit: "%"        description: Sensitivity read from the TEDS or the calibration sheet against the recipe value; the whole test is scaled by it.        validators:          - {operator: ">=", expected_value: -5.0}          - {operator: "<=", expected_value: 5.0}      - name: Unit Mass        key: unit_mass_kg        unit: kg        validators:          - {operator: ">=", expected_value: 1.1}          - {operator: "<=", expected_value: 1.3}      - name: Bracket Torque Min        key: bracket_torque_min_nm        unit: Nm        validators:          - {operator: ">=", expected_value: 2.25}      - name: Bracket Torque Max        key: bracket_torque_max_nm        unit: Nm        validators:          - {operator: "<=", expected_value: 2.75}main:  - name: Axis X    key: axis_x    python: phases.axis_x    timeout: 30m    measurements:      - name: Survey Pre X        key: survey_pre_x        title: Low-level sine signature before the random run, X axis        x_axis:          legend: Frequency          unit: Hz        y_axis:          - legend: Transmissibility            key: transmissibility            unit: g/g            aggregations:              - type: f1_hz                unit: Hz                validators:                  - {operator: ">=", expected_value: 100.0}              - type: q                validators:                  - {operator: "<=", expected_value: 30.0}      - name: Random X        key: random_x        title: Random acceptance run, control and response ASD against the reference, X axis        x_axis:          legend: Frequency          unit: Hz        y_axis:          - legend: Reference            key: reference            unit: g²/Hz          - legend: Control            key: control            unit: g²/Hz            aggregations:              - type: grms                unit: grms                validators:                  - {operator: ">=", expected_value: 9.0}                  - {operator: "<=", expected_value: 11.0}              - type: spectral_dev_db                unit: dB                validators:                  - {operator: "<=", expected_value: 1.5}              - type: duration_s                unit: s                validators:                  - {operator: ">=", expected_value: 60.0}          - legend: Response            key: response            unit: g²/Hz            aggregations:              - type: grms                unit: grms                validators:                  - {operator: "<=", expected_value: 40.0}      - name: Survey Post X        key: survey_post_x        title: Low-level sine signature after the random run, X axis        x_axis:          legend: Frequency          unit: Hz        y_axis:          - legend: Transmissibility            key: transmissibility            unit: g/g            aggregations:              - type: f1_shift_pct                unit: "%"                validators:                  - {operator: "<=", expected_value: 5.0}              - type: q_change_pct                unit: "%"                validators:                  - {operator: "<=", expected_value: 20.0}  - name: Axis Y    key: axis_y    python: phases.axis_y    depends_on: [axis_x]    timeout: 30m    measurements:      - name: Survey Pre Y        key: survey_pre_y        title: Low-level sine signature before the random run, Y axis        x_axis:          legend: Frequency          unit: Hz        y_axis:          - legend: Transmissibility            key: transmissibility            unit: g/g            aggregations:              - type: f1_hz                unit: Hz                validators:                  - {operator: ">=", expected_value: 100.0}              - type: q                validators:                  - {operator: "<=", expected_value: 30.0}      - name: Random Y        key: random_y        title: Random acceptance run, control and response ASD against the reference, Y axis        x_axis:          legend: Frequency          unit: Hz        y_axis:          - legend: Reference            key: reference            unit: g²/Hz          - legend: Control            key: control            unit: g²/Hz            aggregations:              - type: grms                unit: grms                validators:                  - {operator: ">=", expected_value: 9.0}                  - {operator: "<=", expected_value: 11.0}              - type: spectral_dev_db                unit: dB                validators:                  - {operator: "<=", expected_value: 1.5}              - type: duration_s                unit: s                validators:                  - {operator: ">=", expected_value: 60.0}          - legend: Response            key: response            unit: g²/Hz            aggregations:              - type: grms                unit: grms                validators:                  - {operator: "<=", expected_value: 40.0}      - name: Survey Post Y        key: survey_post_y        title: Low-level sine signature after the random run, Y axis        x_axis:          legend: Frequency          unit: Hz        y_axis:          - legend: Transmissibility            key: transmissibility            unit: g/g            aggregations:              - type: f1_shift_pct                unit: "%"                validators:                  - {operator: "<=", expected_value: 5.0}              - type: q_change_pct                unit: "%"                validators:                  - {operator: "<=", expected_value: 20.0}  - name: Axis Z    key: axis_z    python: phases.axis_z    depends_on: [axis_y]    timeout: 30m    measurements:      - name: Survey Pre Z        key: survey_pre_z        title: Low-level sine signature before the random run, Z axis        x_axis:          legend: Frequency          unit: Hz        y_axis:          - legend: Transmissibility            key: transmissibility            unit: g/g            aggregations:              - type: f1_hz                unit: Hz                validators:                  - {operator: ">=", expected_value: 100.0}              - type: q                validators:                  - {operator: "<=", expected_value: 30.0}      - name: Random Z        key: random_z        title: Random acceptance run, control and response ASD against the reference, Z axis        x_axis:          legend: Frequency          unit: Hz        y_axis:          - legend: Reference            key: reference            unit: g²/Hz          - legend: Control            key: control            unit: g²/Hz            aggregations:              - type: grms                unit: grms                validators:                  - {operator: ">=", expected_value: 9.0}                  - {operator: "<=", expected_value: 11.0}              - type: spectral_dev_db                unit: dB                validators:                  - {operator: "<=", expected_value: 1.5}              - type: duration_s                unit: s                validators:                  - {operator: ">=", expected_value: 60.0}          - legend: Response            key: response            unit: g²/Hz            aggregations:              - type: grms                unit: grms                validators:                  - {operator: "<=", expected_value: 40.0}      - name: Survey Post Z        key: survey_post_z        title: Low-level sine signature after the random run, Z axis        x_axis:          legend: Frequency          unit: Hz        y_axis:          - legend: Transmissibility            key: transmissibility            unit: g/g            aggregations:              - type: f1_shift_pct                unit: "%"                validators:                  - {operator: "<=", expected_value: 5.0}              - type: q_change_pct                unit: "%"                validators:                  - {operator: "<=", expected_value: 20.0}teardown:  - name: Unmount    key: unmount    python: phases.unmount    measurements:      - name: Exposure Added        key: exposure_added_s        unit: s

Framework features to notice:

  1. One function, three phases. phases/vibration.py runs the survey, the random run and the survey for one axis; axis_x, axis_y and axis_z each call it with their axis name, and the function addresses survey_pre_<axis>, random_<axis> and survey_post_<axis> with getattr.
  2. Three curves, two judged. random_<axis> records the reference, the control and the response ASDs; the pass/fail lives on the control's grms, spectral_dev_db and duration_s, and on the response's grms. The reference curve is there for the chart and the record.
  3. A window and a per-line tolerance on the same data. control.grms in 9.0 to 11.0 bounds the overall level; spectral_dev_db <= 1.5 bounds the worst line. A run can pass one and fail the other.
  4. Before versus after as aggregations of the after curve. survey_post_<axis> carries f1_shift_pct and q_change_pct, computed in the same phase from the pre-run signature it still has in hand; the dashboard shows both curves and the two numbers together.
  5. A gate on the fixture. The setup measurements say nothing about the unit's quality; they say the sensor, the mass and the bolts are what the test assumes.
  6. Integrate in the phase. Plug calls cross a JSON boundary, so the grms integration and the peak picking are phase-side numpy, not plug methods handed arrays.

Identify

The setup phase reads the controller's identity, the control accelerometer's sensitivity against the recipe value, the unit's mass and the four bracket torques, and records the fixture on the unit metadata:

phases/identify.py
import numpy as npfrom utils.recipe import BRACKET_TORQUE_NM, CONTROL_ACCEL_MV_PER_Gdef identify(measurements, bench, unit, log):    """Setup: control accelerometer sensitivity against its calibration,    unit mass, the four bracket bolts at torque, controller identified.    Nothing shakes until the fixture is right."""    ident = bench.identify()    torque = bench.bracket_torque_nm()    measurements.controller_id = ident["controller"]    measurements.control_accel_error_pct = float(100.0 * (ident["control_accel_mv_per_g"] - CONTROL_ACCEL_MV_PER_G) / CONTROL_ACCEL_MV_PER_G)    measurements.unit_mass_kg = ident["unit_mass_kg"]    measurements.bracket_torque_min_nm = float(min(torque))    measurements.bracket_torque_max_nm = float(max(torque))    unit.metadata["fixture"] = "FX-OBC-04"    log.info(f"Unit {unit.serial_number}: {ident['unit_mass_kg']:.3f} kg, control accel {ident['control_accel_mv_per_g']:.3f} mV/g, bolts {min(torque):.2f}..{max(torque):.2f} Nm against {BRACKET_TORQUE_NM} Nm")

Axis X, Y and Z

Each axis phase is three lines that call the shared function:

phases/axis_x.py
from phases.vibration import run_axisdef axis_x(measurements, bench, log):    """X axis: survey, random at acceptance level, survey."""    run_axis(measurements, bench, log, "x")

The shared function runs the survey, picks the first mode, runs the random test and computes the three control aggregations and the response level, runs the survey again and computes the shift and the amplification change:

phases/vibration.py · 62 lines
phases/vibration.py
import numpy as npfrom utils.recipe import RANDOM_DURATION_Sdef grms(f, asd):    """Overall level from an ASD: the square root of its integral."""    return float(np.sqrt(np.trapezoid(np.asarray(asd), np.asarray(f))))def signature(f, t):    """First mode and its amplification from a transmissibility curve."""    f = np.asarray(f); t = np.asarray(t)    peaks = np.flatnonzero((t[1:-1] > t[:-2]) & (t[1:-1] > t[2:]) & (t[1:-1] > 3.0)) + 1    i = peaks[0] if peaks.size else int(t.argmax())    # Parabolic interpolation in log-log around the peak: the sweep's    # 1/24 octave lines are 3 % apart, the shift criterion is 5 %.    x = np.log(f[i - 1:i + 2]); y = np.log(t[i - 1:i + 2])    a, b, c = np.polyfit(x, y, 2)    xp = -b / (2.0 * a)    return float(np.exp(xp)), float(np.exp(a * xp ** 2 + b * xp + c))def run_axis(measurements, bench, log, axis):    """One axis: low-level sine survey, random run at the acceptance    level, sine survey again. The two signatures are compared; the    random run is judged on the control channel against the reference."""    bench.set_axis(axis)    pre = bench.sine_survey()    f1_pre, q_pre = signature(pre["freq_hz"], pre["transmissibility"])    m = getattr(measurements, f"survey_pre_{axis}")    m.x_axis = pre["freq_hz"]    m.y_axis.transmissibility = pre["transmissibility"]    m.y_axis.transmissibility.aggregations.f1_hz = f1_pre    m.y_axis.transmissibility.aggregations.q = q_pre    run = bench.random_run()    f = np.array(run["freq_hz"])    ctrl_grms = grms(f, run["control"])    ref_grms = grms(f, run["reference"])    resp_grms = grms(f, run["response"])    dev_db = 10.0 * np.log10(np.array(run["control"]) / np.array(run["reference"]))    m = getattr(measurements, f"random_{axis}")    m.x_axis = run["freq_hz"]    m.y_axis.reference = run["reference"]    m.y_axis.control = run["control"]    m.y_axis.control.aggregations.grms = ctrl_grms    m.y_axis.control.aggregations.spectral_dev_db = float(np.abs(dev_db).max())    m.y_axis.control.aggregations.duration_s = float(run["duration_s"])    m.y_axis.response = run["response"]    m.y_axis.response.aggregations.grms = resp_grms    post = bench.sine_survey()    f1_post, q_post = signature(post["freq_hz"], post["transmissibility"])    m = getattr(measurements, f"survey_post_{axis}")    m.x_axis = post["freq_hz"]    m.y_axis.transmissibility = post["transmissibility"]    m.y_axis.transmissibility.aggregations.f1_shift_pct = float(100.0 * abs(f1_post - f1_pre) / f1_pre)    m.y_axis.transmissibility.aggregations.q_change_pct = float(100.0 * abs(q_post - q_pre) / q_pre)    bench.stop()    log.info(f"{axis.upper()}: f1 {f1_pre:.1f} Hz Q {q_pre:.1f}; random {ctrl_grms:.2f} grms control ({ref_grms:.2f} reference), worst line {np.abs(dev_db).max():.2f} dB, {resp_grms:.1f} grms on the unit, {run['duration_s']:.0f} s; post f1 {f1_post:.1f} Hz, shift {100.0 * abs(f1_post - f1_pre) / f1_pre:.2f} %")

Log-log plot of ASD against frequency from 20 to 2000 Hz for the Z axis: the GEVS reference profile as a red dashed line inside a shaded plus and minus 1.5 dB band, the control spectrum in green tracking it, and the response on the unit in orange rising to a peak near 10 g²/Hz at 340 Hz.

The Z axis random run on the mock: the control channel at 9.98 grms against the 10.0 reference with a worst line 0.76 dB off, and the response on the unit at 22.6 grms with its first mode at 340 Hz amplifying the input by a factor of twelve at the peak. Across the three axes the control held 9.98 to 9.99 grms with worst lines of 0.69 to 0.97 dB, and the signatures shifted 0.52 to 0.95 %.

Unmount

The teardown switches the shaker off and adds the three minutes of acceptance random to the serial's exposure record on the unit metadata:

phases/unmount.py
from utils.recipe import AXES, RANDOM_DURATION_Sdef unmount(measurements, bench, unit, log):    """Teardown: shaker off, exposure added to the serial's record."""    bench.stop()    exposure = float(len(AXES) * RANDOM_DURATION_S)    measurements.exposure_added_s = exposure    unit.metadata["vibration_exposure_added_s"] = exposure    log.info(f"Shaker off, {exposure:.0f} s of acceptance random added to the serial's exposure log")

Mock Plug

ShakerBench stands in for the controller, the shaker and the accelerometers. It draws a unit with a first mode at 210, 260 and 340 Hz in X, Y and Z with amplifications of 12 to 16 and a second mode at 2.6 times the first, a sine sweep on 1/24-octave lines from 5 to 2000 Hz, a random run whose control spectrum sits within a 0.3 dB standard deviation of the GEVS reference and whose response is the control times the transmissibility squared, and a bracket joint that lowers the first mode by 0.4 to 0.8 % at each random run. The sweeps and the run return in one call each. Every method returns plain Python types because plug calls cross a JSON boundary, which is also why the integration and the peak picking live in the phase.

On a real station, the class speaks the controller's API for the sine sweep and the random run with the control and response ASDs read back, reads the accelerometers' TEDS for the sensitivities, and reads the torque wrench log for the bolts. Set TIME_SCALE = 1.0 in utils/recipe.py, set RANDOM_PROFILE and RANDOM_GRMS_TARGET to the programme's level, and keep the vibration-exposure log per serial in the dashboard. The phases, measurements and limits stay the same.

Run your first test in minutes