Automotive & data work

Find the dominant frequency of synthetic vibration data with FFT

Use NumPy FFT on a synthetic vibration signal with a known 2 Hz component, inspect the frequency bins, and verify that the detected dominant frequency matches the input. The original CSV remains unchanged and the spectrum is written to a new output folder.

Show contents

Who this is forThis guide is for people who want a small checkable example before applying FFT-based frequency analysis to automotive vibration or sensor data.

What you need
  • Python 3.12 and a terminal command that starts that version.
  • NumPy installed with python -m pip install numpy.
  • A working folder where the script can create a new folder beneath outputs.
  • Basic understanding that a sampled signal has a sampling frequency and discrete time interval.

01Start with a frequency that is known in advance

The safest first FFT exercise is a signal whose frequency you already know. This example uses 8 samples per second and a pure 2 Hz synthetic vibration signal. With 8 samples, the FFT frequency spacing is sampling_rate / sample_count = 8 / 8 = 1 Hz.

For real-valued input, NumPy's rfft returns the non-negative frequency bins only. With 8 samples at 8 Hz, those bins are 0, 1, 2, 3, and 4 Hz. The 4 Hz bin is the Nyquist frequency.

02Create the synthetic vibration CSV

The following dataset is synthetic and was written specifically for this article. Save it as vibration_data.csv. The sampling interval is 0.125 seconds, so the sampling frequency is 1 / 0.125 = 8 Hz.

csv
time_s,accel_m_s2
0.000,0
0.125,1
0.250,0
0.375,-1
0.500,0
0.625,1
0.750,0
0.875,-1

The values repeat every four samples: 0, 1, 0, -1. At 8 samples per second, four samples correspond to 0.5 seconds, and a period of 0.5 seconds corresponds to 2 Hz. The signal therefore has a known dominant input frequency of 2 Hz.

QuantityValue
Samples8
Sample interval0.125 s
Sampling frequency8 Hz
FFT frequency spacing1 Hz
Known signal frequency2 Hz
Nyquist frequency4 Hz

03Predict the FFT result before running code

Because the record contains an integer number of cycles and the 2 Hz signal falls exactly on an FFT bin, the synthetic example is deliberately clean. The raw FFT magnitude should be concentrated at 2 Hz.

For the sequence 0, 1, 0, -1, 0, 1, 0, -1, the 2 Hz FFT coefficient has magnitude 4. The other non-negative bins have zero magnitude in exact arithmetic. Small numerical roundoff can appear in computer calculations, so the exported spectrum is rounded to six decimal places.

FrequencyExpected raw FFT magnitude
0 Hz0
1 Hz0
2 Hz4
3 Hz0
4 Hz0

The 0 Hz bin represents the mean or DC component. This signal has mean 0. The script subtracts the mean before the FFT anyway, which is a common preparation step when the goal is to inspect oscillatory content rather than a constant sensor offset.

04Calculate the spectrum and dominant frequency

Save the following script as fft_dominant_frequency.py. It checks the timestamp spacing, removes the mean, calculates the real FFT, writes the frequency spectrum to a new CSV, and verifies that the strongest nonzero-frequency bin is the expected 2 Hz.

python
import csv
from pathlib import Path
import numpy as np

SOURCE = Path("vibration_data.csv")
OUTPUT_DIR = Path("outputs") / "fft_result"
OUTPUT = OUTPUT_DIR / "spectrum.csv"
EXPECTED_DOMINANT_HZ = 2.0
DT_TOLERANCE = 1e-12
FREQUENCY_TOLERANCE = 1e-12


def main() -> None:
    if not SOURCE.is_file():
        raise FileNotFoundError(f"Source CSV not found: {SOURCE}")
    if OUTPUT_DIR.exists():
        raise FileExistsError(f"Output folder already exists: {OUTPUT_DIR}")

    times = []
    values = []
    with SOURCE.open("r", encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream)
        required = {"time_s", "accel_m_s2"}
        if reader.fieldnames is None or not required.issubset(reader.fieldnames):
            raise ValueError("CSV is missing a required column.")

        for row in reader:
            times.append(float(row["time_s"]))
            values.append(float(row["accel_m_s2"]))

    if len(times) < 2:
        raise ValueError("At least two samples are required.")

    time_array = np.asarray(times, dtype=float)
    signal = np.asarray(values, dtype=float)
    intervals = np.diff(time_array)
    dt = intervals[0]

    if dt <= 0:
        raise ValueError("Sample interval must be positive.")
    if not np.allclose(intervals, dt, rtol=0.0, atol=DT_TOLERANCE):
        raise ValueError("Sampling interval is not constant.")

    sample_rate = 1.0 / dt
    centered = signal - signal.mean()
    spectrum = np.fft.rfft(centered)
    frequencies = np.fft.rfftfreq(len(centered), d=dt)
    magnitudes = np.abs(spectrum)

    if len(frequencies) < 2:
        raise ValueError("No nonzero frequency bin is available.")

    dominant_index = 1 + int(np.argmax(magnitudes[1:]))
    dominant_frequency = float(frequencies[dominant_index])

    if abs(dominant_frequency - EXPECTED_DOMINANT_HZ) > FREQUENCY_TOLERANCE:
        raise RuntimeError(
            f"Expected {EXPECTED_DOMINANT_HZ} Hz, got {dominant_frequency} Hz."
        )

    OUTPUT_DIR.parent.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir()
    with OUTPUT.open("x", encoding="utf-8", newline="") as stream:
        writer = csv.writer(stream)
        writer.writerow(["frequency_hz", "fft_magnitude"])
        for frequency, magnitude in zip(frequencies, magnitudes):
            writer.writerow([f"{frequency:.6f}", f"{magnitude:.6f}"])

    resolution = sample_rate / len(centered)
    print(f"Samples: {len(centered)}.")
    print(f"Sampling frequency: {sample_rate:.1f} Hz.")
    print(f"Frequency resolution: {resolution:.1f} Hz.")
    print(f"Dominant nonzero frequency: {dominant_frequency:.1f} Hz.")
    print(f"Output: {OUTPUT.as_posix()}")


if __name__ == "__main__":
    main()

05Compare the expected spectrum

The spectrum CSV should contain five rows because an 8-sample real FFT returns bins from 0 through the 4 Hz Nyquist frequency. Rounded to six decimal places, only the 2 Hz bin should have a nonzero magnitude.

csv
frequency_hz,fft_magnitude
0.000000,0.000000
1.000000,0.000000
2.000000,4.000000
3.000000,0.000000
4.000000,0.000000

The expected console output below was derived by hand from the synthetic signal and script. It is not a captured execution log.

text
Samples: 8.
Sampling frequency: 8.0 Hz.
Frequency resolution: 1.0 Hz.
Dominant nonzero frequency: 2.0 Hz.
Output: outputs/fft_result/spectrum.csv

06Check the result and common mistakes

  • Check the sample interval manually: 0.125 seconds.
  • Calculate the sampling frequency manually: 1 / 0.125 = 8 Hz.
  • Calculate the frequency spacing: 8 / 8 = 1 Hz.
  • Confirm that the largest nonzero FFT magnitude occurs at 2 Hz.
  • Confirm that the original vibration_data.csv is unchanged.
  • Run the script again without changing OUTPUT_DIR. It should stop with FileExistsError instead of overwriting the spectrum.
ProblemWhat to check
Dominant frequency is wrong by a scale factorCheck the time unit and sampling interval used in rfftfreq.
0 Hz is the largest componentA nonzero mean or sensor offset may dominate; decide whether mean removal is appropriate.
Energy appears in neighboring binsThe signal frequency may not fall exactly on an FFT bin, or the record may not contain an integer number of cycles.
High-frequency result looks suspiciousCheck the Nyquist limit and whether the sampling rate is high enough for the physical signal.
Different runs overwrite resultsUse a fresh output folder; this example deliberately stops when the destination already exists.

07Understand the limits before using measured vibration data

This example is intentionally ideal. Real vibration records rarely align perfectly with FFT bins, so spectral leakage can spread energy into neighboring frequencies. Window functions are commonly used to manage leakage, but they also change amplitude interpretation and require their own checks.

Frequency resolution depends on record length as well as sampling rate. With 8 samples at 8 Hz, the spacing is 1 Hz, so this analysis cannot distinguish frequencies such as 2.1 Hz and 2.4 Hz as separate exact bins. A longer record gives finer frequency spacing when the sampling rate is unchanged.

For measured automotive data, also consider anti-alias filtering, sensor bandwidth, sampling synchronization, dropped samples, detrending, window choice, amplitude scaling, and whether the signal is stationary enough for one FFT to be meaningful. Preserve the raw time-domain data and document every preprocessing step before interpreting peaks physically.

Execution and verification record

2026-09-20 · hand-checked example · target: Python 3.12 · NumPy required · no execution

  • Manually checked that the 8 timestamps are separated by 0.125 seconds, giving an 8 Hz sampling frequency.
  • Manually calculated the FFT frequency spacing as 8 Hz / 8 samples = 1 Hz.
  • Identified the repeating four-sample pattern 0, 1, 0, -1 as a 0.5-second period, corresponding to 2 Hz.
  • Manually determined the expected non-negative FFT magnitudes as 0, 0, 4, 0, 0 in exact arithmetic.
  • Inspected the script for constant-sampling checks, mean removal, rfft, rfftfreq, exclusion of the DC bin from dominant-frequency selection, output collision protection, and preservation of the original CSV.
  • Derived the expected spectrum CSV and console output by hand.
Verification limits
  • The code was not executed by the author of this response; NumPy FFT output and filesystem writes were not tested here.
  • Spectral leakage, window functions, amplitude calibration, irregular sampling, aliasing, sensor noise, and long datasets were not tested.
  • The synthetic peak at 2 Hz is a known mathematical input and is not claimed to represent a real vehicle resonance or fault frequency.
  • The official documentation URLs were provided from known documentation locations but were not checked live.

Site-wide writing and verification principles

References

The explanations and examples were written for this site. See the official sources below for the related behavior and concepts.