자동차·데이터 실무

FFT로 합성 진동 신호의 지배 주파수 찾기

알려진 2 Hz 성분을 가진 합성 진동 신호에 NumPy FFT를 적용하고 주파수 bin을 확인한 뒤 검출된 지배 주파수가 입력값과 일치하는지 검증합니다. 원본 CSV는 유지하고 스펙트럼은 새 출력 폴더에 저장합니다.

목차 보기

이런 분께 맞아요자동차 진동이나 센서 데이터에 FFT 주파수 분석을 적용하기 전에 작고 직접 검증할 수 있는 예제를 먼저 확인하려는 사람을 위한 안내입니다.

준비할 것
  • Python 3.12와 해당 버전을 실행하는 터미널 명령이 준비되어 있어야 합니다.
  • python -m pip install numpy 명령으로 NumPy가 설치되어 있어야 합니다.
  • 스크립트가 outputs 아래에 새 폴더를 만들 수 있는 작업 폴더가 필요합니다.
  • 샘플링된 신호에는 샘플링 주파수와 이산 시간 간격이 있다는 기본 개념을 알고 있어야 합니다.

01주파수를 알고 있는 신호부터 시작하기

FFT를 처음 검증할 때는 주파수를 미리 알고 있는 신호를 사용하는 것이 안전합니다. 이 예제는 초당 8개 샘플과 순수한 2 Hz 합성 진동 신호를 사용합니다. 샘플이 8개이므로 FFT 주파수 간격은 sampling_rate / sample_count = 8 / 8 = 1 Hz입니다.

실수 입력에 대해 NumPy의 rfft는 음수가 아닌 주파수 bin만 반환합니다. 8 Hz로 8개 샘플을 사용하면 주파수 bin은 0, 1, 2, 3, 4 Hz입니다. 4 Hz는 Nyquist 주파수입니다.

02합성 진동 CSV 만들기

아래 데이터는 이 글을 위해 작성한 합성 데이터입니다. vibration_data.csv로 저장하세요. 샘플 간격은 0.125초이므로 샘플링 주파수는 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

값은 0, 1, 0, -1의 네 샘플 패턴을 반복합니다. 초당 8개 샘플에서 네 샘플은 0.5초에 해당하고, 주기가 0.5초인 신호의 주파수는 2 Hz입니다. 따라서 이 신호의 알려진 지배 입력 주파수는 2 Hz입니다.

항목
샘플 수8
샘플 간격0.125 s
샘플링 주파수8 Hz
FFT 주파수 간격1 Hz
알려진 신호 주파수2 Hz
Nyquist 주파수4 Hz

03코드 실행 전에 FFT 결과 예상하기

이 기록은 정수 개수의 주기를 포함하고 2 Hz 신호가 FFT bin에 정확히 일치하므로 의도적으로 매우 깨끗한 예제입니다. 원시 FFT magnitude는 2 Hz에 집중되어야 합니다.

0, 1, 0, -1, 0, 1, 0, -1 수열에서 2 Hz FFT 계수의 magnitude는 4입니다. 정확한 수학 계산에서는 다른 음수가 아닌 bin의 magnitude가 0입니다. 실제 컴퓨터 계산에서는 매우 작은 수치 반올림 오차가 나타날 수 있으므로 출력 스펙트럼은 소수점 이하 6자리로 기록합니다.

주파수예상 raw FFT magnitude
0 Hz0
1 Hz0
2 Hz4
3 Hz0
4 Hz0

0 Hz bin은 평균 또는 DC 성분을 나타냅니다. 이 신호의 평균은 0입니다. 스크립트에서는 일정한 센서 오프셋이 아니라 진동 성분을 살펴보는 상황을 가정해 FFT 전에 평균을 제거합니다.

04스펙트럼과 지배 주파수 계산하기

다음 스크립트를 fft_dominant_frequency.py로 저장하세요. 타임스탬프 간격을 검사하고 평균을 제거한 뒤 실수 FFT를 계산합니다. 주파수 스펙트럼은 새 CSV로 저장하고, 0 Hz를 제외한 가장 큰 주파수 bin이 예상한 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()

05예상 스펙트럼과 비교하기

8샘플 실수 FFT는 0 Hz부터 4 Hz Nyquist 주파수까지의 bin을 반환하므로 spectrum.csv에는 데이터 행이 5개 있어야 합니다. 소수점 이하 6자리로 반올림하면 2 Hz bin만 0이 아닌 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

아래 예상 콘솔 출력은 합성 신호와 스크립트를 바탕으로 손으로 도출한 결과이며 실제 실행 로그가 아닙니다.

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

06결과와 자주 발생하는 오류 확인하기

  • 샘플 간격을 직접 확인합니다. 0.125초입니다.
  • 샘플링 주파수를 계산합니다. 1 / 0.125 = 8 Hz입니다.
  • 주파수 간격을 계산합니다. 8 / 8 = 1 Hz입니다.
  • 0 Hz를 제외한 가장 큰 FFT magnitude가 2 Hz에 있는지 확인합니다.
  • 원본 vibration_data.csv가 변경되지 않았는지 확인합니다.
  • OUTPUT_DIR을 바꾸지 않고 다시 실행합니다. 기존 스펙트럼을 덮어쓰지 않고 FileExistsError로 중단되어야 합니다.
문제확인할 사항
지배 주파수가 일정 배율로 틀림rfftfreq에 사용한 시간 단위와 샘플 간격을 확인하세요.
0 Hz가 가장 큼평균값이나 센서 오프셋이 클 수 있습니다. 평균 제거가 분석 목적에 적절한지 판단하세요.
주변 bin에도 성분이 퍼짐신호 주파수가 FFT bin에 정확히 맞지 않거나 기록에 정수 개수의 주기가 포함되지 않았을 수 있습니다.
고주파 결과가 의심스러움Nyquist 한계와 실제 신호에 비해 샘플링 주파수가 충분한지 확인하세요.
다시 실행할 때 결과가 덮어써짐새 출력 폴더를 사용하세요. 이 예제는 기존 대상이 있으면 의도적으로 중단합니다.

07실제 진동 데이터에 적용하기 전 한계 이해하기

이 예제는 의도적으로 이상적인 조건입니다. 실제 진동 기록의 주파수는 FFT bin에 정확히 맞지 않는 경우가 많아 spectral leakage로 에너지가 주변 주파수에 퍼질 수 있습니다. Window 함수는 leakage를 다루는 데 자주 사용되지만 amplitude 해석에도 영향을 주므로 별도의 검증이 필요합니다.

주파수 분해능은 샘플링 주파수뿐 아니라 기록 길이에도 영향을 받습니다. 이 예제는 8 Hz에서 8샘플을 사용하므로 간격이 1 Hz입니다. 따라서 2.1 Hz와 2.4 Hz 같은 주파수를 서로 다른 정확한 FFT bin으로 구분할 수 없습니다. 같은 샘플링 주파수에서 기록을 더 길게 하면 주파수 간격을 더 세밀하게 만들 수 있습니다.

실제 자동차 계측에서는 anti-alias filter, 센서 대역폭, 채널 동기화, 누락 샘플, detrending, window 선택, amplitude scaling, 신호의 정상성도 함께 고려해야 합니다. FFT peak를 물리적으로 해석하기 전에 원본 시간영역 데이터를 보존하고 모든 전처리 과정을 기록하세요.

실행·검증 기록

2026-09-20 · 예제 수동 검토 · 대상: Python 3.12 · NumPy 필요 · 실행하지 않음

  • 8개 타임스탬프가 0.125초 간격이며 샘플링 주파수가 8 Hz임을 수동으로 확인했습니다.
  • FFT 주파수 간격을 8 Hz / 8 samples = 1 Hz로 수동 계산했습니다.
  • 0, 1, 0, -1의 네 샘플 반복 패턴이 0.5초 주기이며 2 Hz에 해당함을 확인했습니다.
  • 정확한 수학 계산에서 음수가 아닌 FFT magnitude가 0, 0, 4, 0, 0이 됨을 수동으로 확인했습니다.
  • 일정 샘플링 검사, 평균 제거, rfft, rfftfreq, 지배 주파수 선택 시 DC bin 제외, 출력 충돌 방지, 원본 CSV 보존 로직을 코드로 검토했습니다.
  • 예상 spectrum.csv와 콘솔 출력을 손으로 도출했습니다.
검증 한계
  • 이 응답의 작성자는 코드를 실행하지 않았으며, NumPy FFT 출력이나 파일 시스템 쓰기를 실제로 시험하지 않았습니다.
  • Spectral leakage, window 함수, amplitude calibration, 비균일 샘플링, aliasing, 센서 노이즈, 대규모 데이터는 시험하지 않았습니다.
  • 2 Hz 합성 peak는 알려진 수학적 입력이며 실제 차량 공진이나 고장 주파수를 나타낸다고 주장하지 않습니다.
  • 공식 문서 URL은 알려진 문서 위치를 사용했지만 실시간으로 접속해 확인하지 않았습니다.

사이트 전체 작성·검증 원칙

참고 출처

설명과 예제는 직접 작성했습니다. 관련 동작과 개념은 아래 공식 자료에서 확인할 수 있습니다.