Check the time axis and units of a test CSV first
Check units, missing values, finite numbers, and duplicate or reversed timestamps in synthetic demonstration data, then save the check results and a chart.
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.
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.
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.
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.
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.
| Quantity | Value |
|---|---|
| Samples | 8 |
| Sample interval | 0.125 s |
| Sampling frequency | 8 Hz |
| FFT frequency spacing | 1 Hz |
| Known signal frequency | 2 Hz |
| Nyquist frequency | 4 Hz |
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.
| Frequency | Expected raw FFT magnitude |
|---|---|
| 0 Hz | 0 |
| 1 Hz | 0 |
| 2 Hz | 4 |
| 3 Hz | 0 |
| 4 Hz | 0 |
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.
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.
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()
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.
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.
Samples: 8.
Sampling frequency: 8.0 Hz.
Frequency resolution: 1.0 Hz.
Dominant nonzero frequency: 2.0 Hz.
Output: outputs/fft_result/spectrum.csv| Problem | What to check |
|---|---|
| Dominant frequency is wrong by a scale factor | Check the time unit and sampling interval used in rfftfreq. |
| 0 Hz is the largest component | A nonzero mean or sensor offset may dominate; decide whether mean removal is appropriate. |
| Energy appears in neighboring bins | The 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 suspicious | Check the Nyquist limit and whether the sampling rate is high enough for the physical signal. |
| Different runs overwrite results | Use a fresh output folder; this example deliberately stops when the destination already exists. |
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.
ChatGPT Python tool · Python 3.13.5 · NumPy 2.3.5 · standard library: csv, pathlib
The explanations and examples were written for this site. See the official sources below for the related behavior and concepts.