실습 A. Python으로 신호 다루기
컴퓨터 안에서 신호는 숫자 배열 하나이다. 표본화율, 길이, 주파수 세 숫자로 그 배열을 만들고, 시간 파형, I/Q 평면, 스펙트럼 세 가지 그림으로 확인한다. 이 실습에서 익힌 방법을 뒤의 모든 실습이 그대로 쓴다.
이 실습의 자리: 시뮬레이션 — 신호를 배열로 만들고 그리는 법을 익히며, 다음 실습 B에서 그 배열의 DFT를 직접 계산한다.
준비
활동은 셋이다. 정현파를 만드는 함수를 쓰고, 표본화율과 주파수를 바꿔 가며 파형을 보고, 복소 신호를 만들어 세 가지 그림으로 본다. 신호는 모두 코드로 만든 것이며 Pluto는 쓰지 않는다.
회색 상자가 셀이다. ▶ 실행이나 Shift+Enter로 실행한다. 셀들은 변수를 함께 쓰므로, 위 셀에서 만든 것을 아래 셀이 쓴다. 그래서 셀을 실행하면 그 위의 셀 가운데 아직 실행하지 않았거나 고친 뒤 다시 실행하지 않은 셀이 먼저 차례로 실행된다. 위 셀에서 오류가 나면 거기서 멈추고, 실행한 셀에 어느 셀을 고쳐야 하는지 적힌다. np(numpy)와 show(그림 도구)는 이미 준비되어 있다. 코드를 고친 뒤 되돌리기를 누르면 교재의 코드로 돌아간다.
함수를 쓰는 활동에는 함수의 이름, 입력, 출력, 계산 순서가 있다. 그 순서대로 쓰고, 바로 아래 확인 셀로 맞는지 본다. 구현할 함수에는 # TODO 줄과 그 아래 한 줄이 미리 들어 있다. 두 줄을 지우고 그 자리에 함수를 쓴다. 쓰기 전에 확인 셀을 실행하면 '할 일' 안내가 나온다.
그림 옆의 기록에 넣기 단추를 누르면 그 그림이 기록에 들어가고, 글 상자에 쓴 설명도 함께 저장된다. 다 마치면 도구 막대의 저장을 누른다.
활동 1. 정현파를 배열로 만들기
연속 신호 를 컴퓨터에 담으려면 일정한 간격으로 값을 읽어야 한다. 1초에 읽는 횟수가 표본화율 이고, 번째 표본의 시각은 이다(2장 표본화와 aliasing). 그래서 신호 하나는 배열 두 개로 표현된다. 시각을 담은 t와, 그 시각의 값을 담은 x이다.
numpy 배열은 수식을 그대로 옮겨 쓸 수 있다. 배열에 숫자를 곱하거나 np.cos에 넣으면 원소마다 따로 계산된다. 아래 셀을 실행해 확인한다.
n = np.arange(5) # 0부터 4까지의 정수 배열
print('n =', n)
print('n / 10 =', n / 10) # 원소마다 나눈다
print('표본 수 =', n.size)
쓸 함수 make_tone(frequency, sample_rate, duration)
- 입력: 주파수
frequency(Hz), 표본화율sample_rate(Hz), 길이duration(초) - 출력: 시각 배열
t(초)와 신호 배열x. 두 배열의 길이는 같다.
계산 순서
- 표본 수를 구한다.
duration * sample_rate를round로 정수로 만든다. - 시각 배열을 만든다.
np.arange(표본 수) / sample_rate. - 신호 배열을 만든다.
np.cos(2 * np.pi * frequency * t). return t, x로 둘을 함께 돌려준다.
def make_tone(frequency, sample_rate, duration):
"""cos(2πft)의 표본. (시각 배열, 신호 배열)을 돌려준다."""
# TODO: 위의 계산 순서대로 make_tone 함수를 구현하세요.
raise NotImplementedError('make_tone 함수를 구현하세요.')
아래 셀은 50 Hz 정현파를 1 kHz로 0.1초 동안 만든다. 표본은 100개이고 간격은 1 ms이다. 50 Hz의 한 주기는 20 ms, 곧 표본 20개이므로, 값은 1에서 시작해 5번째 표본에서 0, 10번째 표본에서 −1이 되어야 한다.
sample_rate = 1000 # Hz
duration = 0.1 # 초
t, x = make_tone(50, sample_rate, duration)
print('표본 수:', x.size, ' 표본 간격:', t[1] * 1000, 'ms')
print('x[0], x[5], x[10] =', np.round([x[0], x[5], x[10]], 3) + 0.0)
correct = (
x.size == 100
and t.size == 100
and np.isclose(t[1], 0.001)
and np.allclose([x[0], x[5], x[10]], [1, 0, -1], atol=1e-9)
)
print('→ 맞음' if correct else '→ 다시 본다')
show.time(x, sample_rate, title='50 Hz 정현파 (합성)')
show.time(신호, 표본화율)은 가로축을 시간으로 바꿔 파형을 그린다. 그림에서 한 주기가 20 ms인지 확인한다.
활동 2. 표본을 점으로 보기
앞의 그림은 표본을 선으로 이어 그려서 매끄러운 곡선처럼 보였다. 실제로 배열에 든 것은 표본 간격마다 하나씩 있는 점이다. 한 주기에 들어가는 점의 수는 이다. 주파수를 올리거나 표본화율을 내리면 한 주기를 그리는 점이 줄어든다.
아래 셀은 같은 함수로 만든 신호를 두 번 그린다. 아주 촘촘한 표본으로 만든 선은 연속 신호를 대신하고, 점은 고른 표본화율로 읽은 표본이다. 축은 고정되어 있으므로 손잡이를 움직여도 같은 50 ms 구간을 보게 된다. 주파수와 표본화율을 바꾸며, 한 주기의 점이 몇 개일 때부터 점만으로는 정현파 모양을 알아보기 어려워지는지 본다.
FINE_RATE = 100_000 # Hz, dense grid that stands in for the continuous signal
WINDOW = 0.05 # s, the stretch of time shown
def draw_samples(frequency, sample_rate):
t_fine, continuous = make_tone(frequency, FINE_RATE, WINDOW)
# The samples sit on the dense grid every FINE_RATE / sample_rate points.
step = FINE_RATE // sample_rate
samples = np.full(continuous.size, np.nan)
samples[::step] = continuous[::step]
show.line(
continuous,
samples,
x=t_fine * 1000,
labels=['연속 신호', '표본'],
styles=['line', 'points'],
title=f'{frequency} Hz를 {sample_rate} Hz로 표본화 (합성)',
xlabel='시간',
xunit='ms',
ylabel='x',
xlim=(0, 50),
ylim=(-1.2, 1.2),
)
print('한 주기의 표본 수:', sample_rate / frequency)
show.control(
draw_samples,
frequency=show.slider(50, 400, step=50, value=50, unit='Hz', label='주파수'),
sample_rate=show.choice([1000, 2000, 4000], value=1000, unit='Hz', label='표본화율'),
)
표본화율 1000 Hz에서 주파수를 400 Hz까지 올리면 점들의 모양은 어떻게 되는가? 표본화율을 4000 Hz로 바꾸면 무엇이 달라지는가?
활동 3. 복소 신호와 세 가지 그림
SDR이 내놓는 표본은 실수가 아니라 복소수 이다(2장 복소수와 I/Q). 가장 단순한 복소 신호는 복소 지수 이다. 실수부 I는 코사인, 허수부 Q는 사인이고, 복소 평면에서는 반지름 1인 원 위를 1초에 바퀴 돈다. 가 양수이면 반시계 방향, 음수이면 시계 방향이다.
쓸 함수 make_complex_tone(frequency, sample_rate, duration)
- 입력:
make_tone과 같다.frequency는 음수일 수 있다. - 출력: 시각 배열
t(초)와 복소 신호 배열z
계산 순서
- 표본 수와 시각 배열
t는make_tone과 같은 방법으로 만든다. - 신호 배열을 만든다.
np.exp(2j * np.pi * frequency * t). Python에서2j는 허수 이다. return t, z로 돌려준다.
def make_complex_tone(frequency, sample_rate, duration):
"""exp(j2πft)의 표본. (시각 배열, 복소 신호 배열)을 돌려준다."""
# TODO: 위의 계산 순서대로 make_complex_tone 함수를 구현하세요.
raise NotImplementedError('make_complex_tone 함수를 구현하세요.')
아래 셀은 50 Hz 복소 지수를 만든다. 모든 표본의 크기는 1이어야 한다. 5번째 표본은 4분의 1바퀴 돈 자리이므로 이고, 실수부는 활동 1의 x와 같아야 한다.
t, z = make_complex_tone(50, sample_rate, duration)
print('z[0] =', np.round(z[0], 3), ' z[5] =', np.round(z[5], 3))
print('크기의 최소, 최대:', np.round([np.abs(z).min(), np.abs(z).max()], 6))
correct = (
np.iscomplexobj(z)
and np.allclose(np.abs(z), 1)
and np.isclose(z[5], 1j)
and np.allclose(z.real, x)
)
print('→ 맞음' if correct else '→ 다시 본다')
같은 신호를 세 가지 방법으로 그린다. 뒤의 실습에서 받은 신호를 볼 때도 이 세 그림을 쓴다.
show.time(z, sample_rate): 시간 파형. 복소 신호이면 I와 Q를 따로 그린다.show.iq(z): I/Q 평면. 가로가 I, 세로가 Q이다.show.spectrum(z, sample_rate): 스펙트럼. 어느 주파수에 얼마나 큰 성분이 있는지 dB로 보인다(2장 DTFT와 DFT).
손잡이로 주파수의 부호를 바꾸고, 신호를 복소 지수에서 코사인으로 바꿔 본다. 세 그림 가운데 무엇이 달라지는지 본다.
def draw_three_views(frequency, kind):
t, z = make_complex_tone(frequency, sample_rate, duration)
if kind == '코사인':
signal = z.real
else:
signal = z
show.time(signal, sample_rate, title=f'시간 파형: {kind} {frequency} Hz (합성)', ylim=(-1.2, 1.2))
show.iq(signal, title='I/Q 평면 (합성)', limit=1.2)
show.spectrum(signal, sample_rate, title='스펙트럼 (합성)', ylim=(-120, 5))
show.control(
draw_three_views,
frequency=show.slider(-200, 200, step=50, value=50, unit='Hz', label='주파수'),
kind=show.choice(['복소 지수', '코사인'], value='복소 지수', label='신호'),
)
복소 지수의 주파수를 +50 Hz에서 −50 Hz로 바꾸면 세 그림 가운데 무엇이 바뀌는가? 코사인으로 바꾸면 스펙트럼의 봉우리는 몇 개가 되는가?
복소 지수는 스펙트럼에 봉우리가 하나이고, 주파수의 부호에 따라 봉우리가 0 Hz의 오른쪽이나 왼쪽에 선다. 코사인은 이므로 양쪽에 하나씩 두 개가 선다. I만 보아서는 와 를 구별할 수 없고, Q까지 함께 받아야 구별된다. 수신기가 I와 Q를 둘 다 내놓는 까닭이다.
강의 노트