이동평균으로 합성 센서 데이터를 평활화하고 지연 확인하기
합성 가속도 데이터에 3샘플 후행 이동평균을 적용하고 모든 필터 값을 손으로 검증하며, 필터의 1샘플 정렬 지연을 명시적으로 확인합니다. 원본 CSV는 변경하지 않고 처리 결과를 새 폴더에 저장합니다.
알려진 2 Hz 성분을 가진 합성 진동 신호에 NumPy FFT를 적용하고 주파수 bin을 확인한 뒤 검출된 지배 주파수가 입력값과 일치하는지 검증합니다. 원본 CSV는 유지하고 스펙트럼은 새 출력 폴더에 저장합니다.
이런 분께 맞아요자동차 진동이나 센서 데이터에 FFT 주파수 분석을 적용하기 전에 작고 직접 검증할 수 있는 예제를 먼저 확인하려는 사람을 위한 안내입니다.
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 주파수입니다.
아래 데이터는 이 글을 위해 작성한 합성 데이터입니다. vibration_data.csv로 저장하세요. 샘플 간격은 0.125초이므로 샘플링 주파수는 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
값은 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 |
이 기록은 정수 개수의 주기를 포함하고 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 Hz | 0 |
| 1 Hz | 0 |
| 2 Hz | 4 |
| 3 Hz | 0 |
| 4 Hz | 0 |
0 Hz bin은 평균 또는 DC 성분을 나타냅니다. 이 신호의 평균은 0입니다. 스크립트에서는 일정한 센서 오프셋이 아니라 진동 성분을 살펴보는 상황을 가정해 FFT 전에 평균을 제거합니다.
다음 스크립트를 fft_dominant_frequency.py로 저장하세요. 타임스탬프 간격을 검사하고 평균을 제거한 뒤 실수 FFT를 계산합니다. 주파수 스펙트럼은 새 CSV로 저장하고, 0 Hz를 제외한 가장 큰 주파수 bin이 예상한 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()
8샘플 실수 FFT는 0 Hz부터 4 Hz Nyquist 주파수까지의 bin을 반환하므로 spectrum.csv에는 데이터 행이 5개 있어야 합니다. 소수점 이하 6자리로 반올림하면 2 Hz bin만 0이 아닌 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
아래 예상 콘솔 출력은 합성 신호와 스크립트를 바탕으로 손으로 도출한 결과이며 실제 실행 로그가 아닙니다.
Samples: 8.
Sampling frequency: 8.0 Hz.
Frequency resolution: 1.0 Hz.
Dominant nonzero frequency: 2.0 Hz.
Output: outputs/fft_result/spectrum.csv| 문제 | 확인할 사항 |
|---|---|
| 지배 주파수가 일정 배율로 틀림 | rfftfreq에 사용한 시간 단위와 샘플 간격을 확인하세요. |
| 0 Hz가 가장 큼 | 평균값이나 센서 오프셋이 클 수 있습니다. 평균 제거가 분석 목적에 적절한지 판단하세요. |
| 주변 bin에도 성분이 퍼짐 | 신호 주파수가 FFT bin에 정확히 맞지 않거나 기록에 정수 개수의 주기가 포함되지 않았을 수 있습니다. |
| 고주파 결과가 의심스러움 | Nyquist 한계와 실제 신호에 비해 샘플링 주파수가 충분한지 확인하세요. |
| 다시 실행할 때 결과가 덮어써짐 | 새 출력 폴더를 사용하세요. 이 예제는 기존 대상이 있으면 의도적으로 중단합니다. |
이 예제는 의도적으로 이상적인 조건입니다. 실제 진동 기록의 주파수는 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 필요 · 실행하지 않음
설명과 예제는 직접 작성했습니다. 관련 동작과 개념은 아래 공식 자료에서 확인할 수 있습니다.