
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.

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

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.

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.
- Setup: controller, control accelerometer sensitivity, unit mass, bracket torques.
- Main, X: signature, random run at 10.0 grms for 60 s, signature.
- Main, Y: same.
- Main, Z: same.
- 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
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 --jsonThe 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
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: sFramework features to notice:
- One function, three phases.
phases/vibration.pyruns the survey, the random run and the survey for one axis;axis_x,axis_yandaxis_zeach call it with their axis name, and the function addressessurvey_pre_<axis>,random_<axis>andsurvey_post_<axis>withgetattr. - Three curves, two judged.
random_<axis>records the reference, the control and the response ASDs; the pass/fail lives on the control'sgrms,spectral_dev_dbandduration_s, and on the response'sgrms. The reference curve is there for the chart and the record. - A window and a per-line tolerance on the same data.
control.grmsin 9.0 to 11.0 bounds the overall level;spectral_dev_db <= 1.5bounds the worst line. A run can pass one and fail the other. - Before versus after as aggregations of the after curve.
survey_post_<axis>carriesf1_shift_pctandq_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. - 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.
- 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:
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:
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
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} %")
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:
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.
