REMI Lab강의 노트

Wireless Embedded Systems E14

실습 4. 변조와 복조

알려진 프레임을 받아 포락선(OOK)과 순간 주파수(FSK)로 비트를 되찾고, 보낸 비트와 비교해 틀린 비트를 센다.

이 실습의 자리: Pluto 실습: 통신 — 실습 3의 대역폭 관찰에서 이어져 비트를 복원하고, 다음 실습 5에서 주변 전파의 시간·주파수 모양을 읽는다.

준비

활동은 셋이다. OOK를 판정하는 함수와 FSK의 순간 주파수를 구하는 함수를 쓰고, 마지막에는 감쇠와 비트율을 바꾸며 BER을 관찰한다. 프레임 생성·시작 찾기·장치 설정은 도우미가 맡는다. 그림과 표 옆의 기록에 넣기 단추로 결과를 모으고, 글 상자에 설명을 쓴 뒤 도구 막대의 저장을 누른다.

이론은 4장 반송파 위상 없이 복조하기와 비트율과 SNR을 따른다.

  • 루프백은 내 Pluto의 TX → SMA 케이블 → 30 dB 감쇠기 → RX로 연결한다. 송신 대역은 2400.1–2483.4 MHz 안, TX 감쇠는 10 dB 이상으로 지킨다. 케이블·감쇠기 연결을 확인한 뒤 실행한다.
  • 수신 전용은 RX에 안테나를 달고 강의실 송신 Pluto의 신호를 받는다. 활동에 맞춰 ook 또는 fsk가 나가는지 확인한다. 내 Pluto는 송신하지 않는다.
  • 장치가 없으면 ?device=fake로 연다. 수신 전용 연습은 ?device=fake&signal=ook 또는 ?device=fake&signal=fsk로 따로 연다. 합성 결과에는 합성이 붙는다.
from sdrlab import pluto, show, dsp, modlab
import numpy as np

MODE = 'loopback'  # 안테나로 강의실 송신 신호를 받으면 'receive'
sdr = await pluto.connect()
await sdr.setup('framed-link', mode=MODE)
sample_rate = float(sdr.settings['sample_rate'])

프레임(frame)은 한 번에 보내는 비트 묶음이다. 시작을 찾는 알려진 비트열인 헤더(header), 전달할 내용인 페이로드(payload), 신호를 끈 쉼 순서로 되풀이한다. 헤더와의 상관이 큰 자리를 프레임 시작으로 삼는다. 헤더를 못 찾으면 비트를 억지로 읽지 않고 신호 종류와 연결을 확인한다. 페이로드도 받는 쪽이 알고 있으므로 BER을 셀 수 있고, 쉼에서는 잡음만 따로 잴 수 있다. 도우미의 프레임은 강의실 송신 Pluto가 보내는 프레임(signals.json)과 같다.

활동 1. OOK를 포락선으로 판정하기

반송파의 위상을 몰라도 비트를 읽을 수 있을까? 먼저 반송파를 0 Hz로 옮겨야 한 비트의 표본을 더할 때 신호끼리 상쇄되지 않는다. 한 비트의 W개 복소 표본을 먼저 더하면, 같은 방향인 신호는 W배로 커지고 방향이 제각각인 잡음은 √W배만 커진다. 그 뒤 합의 포락선(envelope), 즉 크기를 구하면 공통 위상과 관계없이 켜짐과 꺼짐을 가른다. 표본마다 크기를 먼저 구하면 잡음도 양수로 쌓인다.

도우미는 캡처의 스펙트럼 봉우리에서 반송파 주파수 carrier_frequency를 찾아 내 함수에 넘긴다.

쓸 함수 decode_ook(x, samples_per_bit, start_index, carrier_frequency, sample_rate)

  • 입력: 길이 N의 수신 복소 배열 x, 비트당 표본 수인 정수 samples_per_bit, 헤더가 시작하는 표본 번호 start_index, 반송파 주파수 carrier_frequency(Hz), 표본화율 sample_rate(Hz). I와 Q는 ADC 최대 크기가 1이 되도록 나눈 값이다. 도우미가 프레임 뒤의 쉼을 잘라 주므로 x[start_index:]에는 한 프레임의 비트만 들어 있다.
  • 출력: start_index 뒤의 온전한 비트들을 담은 정수 배열. 꺼짐은 0, 켜짐은 1이다.

계산 순서

  1. z = x * np.exp(-2j * np.pi * carrier_frequency * np.arange(len(x)) / sample_rate)로 반송파를 0 Hz로 옮긴다.
  2. np.convolve(z, np.ones(samples_per_bit), mode='same')로 한 비트 길이의 복소 합을 구한 뒤 np.abs로 크기 envelope를 구한다. 가운데를 맞춘 합이므로 별도의 지연을 더하지 않는다.
  3. np.percentile(envelope, [10, 90])으로 낮은 높이와 높은 높이를 잡고 np.mean으로 문턱을 구한다. 이 프레임에는 0과 1이 충분히 섞여 있어 두 백분위수가 꺼짐과 켜짐의 높이를 나타낸다.
  4. 비트 수 (len(x) - start_index) // samples_per_bit를 구하고 np.arange로 비트 중심 start_index + (k + 0.5) * samples_per_bit를 만든 뒤 정수로 바꾼다.
  5. 그 자리의 포락선이 문턱보다 크면 1, 아니면 0으로 하여 astype(int)로 돌려준다.
def decode_ook(x, samples_per_bit, start_index, carrier_frequency, sample_rate):
    """수신 배열, 비트당 표본, 시작 위치, 반송파·표본화율(Hz)에서 비트 배열을 돌려준다."""
    # TODO: 위의 계산 순서대로 decode_ook 함수를 구현하세요.
    raise NotImplementedError('decode_ook 함수를 구현하세요.')

아래 합성 확인에서는 비트당 40표본, 시작 번호 80을 쓴다. 반송파 위상을 바꾸어도 [0, 1, 1, 0, 1, 0]이 복원되고 틀린 비트가 0개여야 한다.

expected_bits = np.array([0, 1, 1, 0, 1, 0])
test_samples_per_bit = 40  # 표본/비트
test_start_index = 80  # 표본
amplitude = np.r_[np.zeros(test_start_index), np.repeat(expected_bits, test_samples_per_bit)]
phase_step = 0.37  # rad/표본
initial_phase = 1.2  # rad
test_signal = (0.03 + 0.4 * amplitude) * np.exp(
    1j * (phase_step * np.arange(amplitude.size) + initial_phase)
)

decoded_bits = decode_ook(
    test_signal,
    test_samples_per_bit,
    test_start_index,
    phase_step * sample_rate / (2 * np.pi),
    sample_rate,
)
ook_errors = int(np.sum(decoded_bits != expected_bits))
print(
    '복원 비트:',
    decoded_bits,
    '틀린 비트:',
    ook_errors,
    '→ 맞음' if np.array_equal(decoded_bits, expected_bits) else '→ 다시 본다',
)

이제 받기를 눌러 같은 함수를 캡처에 쓴다. 먼저 한 비트씩 모은 크기와 문턱을 보고, 그다음 알려진 페이로드와 비교한 오류 수를 본다.

async def draw_ook():
    global ook_capture
    ook_capture = await modlab.observe_ook(sdr, MODE, decode_ook)


await show.control(draw_ook, capture=True)

I/Q의 위상을 몰라도 포락선으로 비트를 가를 수 있는 이유는 무엇인가? 오류가 있다면 두 높이가 문턱에서 충분히 떨어져 있는지 설명한다.

활동 2. FSK의 순간 주파수 구하기

진폭이 같은 두 비트를 무엇으로 가를까? FSK는 비트마다 복소 표본이 도는 속도를 바꾼다. 이웃 표본의 켤레 곱은 공통 위상을 없애고 그 사이에 돌아간 각만 남긴다.

f^[n]=fs2πarg⁡{x[n+1]x∗[n]}.\hat f[n]=\frac{f_s}{2\pi}\arg\{x[n+1]x^*[n]\}.

도우미는 두 톤의 가운데를 기저대역으로 옮기고 저역 통과 필터로 대역 밖 잡음을 줄인다. 순간 주파수를 비트 안에서 평균한 뒤, 두 톤의 가운데보다 높으면 1, 낮으면 0으로 판정한다. 수신 전용에서 두 톤의 가운데가 옮겨 간 양에는 두 장치의 클럭 차가 들어 있다. 쉼 구간의 잡음·DC를 비트로 읽지 않도록 헤더와 신호가 켜진 구간을 함께 확인한다.

쓸 함수 inst_freq(x, sample_rate)

  • 입력: 길이 N의 복소 배열 x, 표본화율 sample_rate(Hz).
  • 출력: 길이 N−1의 실수 배열(Hz). 각 값은 이웃한 두 표본 사이의 주파수이다.

계산 순서

  1. np.asarray(x)로 입력을 배열로 받는다.
  2. x[1:] * np.conj(x[:-1])로 이웃 표본의 켤레 곱을 구한다.
  3. np.angle로 각(rad)을 구하고 sample_rate / (2 * np.pi)를 곱해 돌려준다. 절댓값을 취하면 회전 방향을 잃는다.
def inst_freq(x, sample_rate):
    """복소 배열 x와 표본화율(Hz)을 받아 이웃 표본 사이 주파수(Hz)를 돌려준다."""
    # TODO: 위의 계산 순서대로 inst_freq 함수를 구현하세요.
    raise NotImplementedError('inst_freq 함수를 구현하세요.')

확인 신호는 앞에서 +30 kHz, 뒤에서 −10 kHz로 돈다. 경계 표본을 제외한 두 평균이 각각 30.0, −10.0 kHz로 나오면 맞게 쓴 것이다.

sample_indices = np.arange(2000)
positive_frequency = 30e3  # Hz
negative_frequency = -10e3  # Hz
test_frequencies = np.where(sample_indices < 1000, positive_frequency, negative_frequency)
test_signal = np.exp(2j * np.pi * np.cumsum(test_frequencies) / sample_rate)

instantaneous_frequency = inst_freq(test_signal, sample_rate)
front_frequency = np.mean(instantaneous_frequency[:999])
back_frequency = np.mean(instantaneous_frequency[1000:])
mean_frequencies = np.array([front_frequency, back_frequency])
decided_bits = (mean_frequencies > np.mean(mean_frequencies)).astype(int)

correct = (
    instantaneous_frequency.shape == (1999,)
    and np.allclose(mean_frequencies, [30e3, -10e3], atol=1e-6)
    and np.array_equal(decided_bits, [1, 0])
)
print(
    f'앞 {front_frequency / 1e3:.1f} kHz, 뒤 {back_frequency / 1e3:.1f} kHz, 판정 {decided_bits}',
    '→ 맞음' if correct else '→ 다시 본다',
)

강의실 송신 Pluto가 FSK를 보내는지 확인하고 받기를 누른다. 먼저 순간 주파수의 두 높이를 보고, 그다음 페이로드 오류 수를 본다. 순간 주파수의 두 높이가 함께 움직여도 가운데를 판정 기준으로 쓰면 공통 주파수 오차를 따라간다.

async def draw_fsk():
    global fsk_capture
    fsk_capture = await modlab.observe_fsk(sdr, MODE, inst_freq)


await show.control(draw_fsk, capture=True)

OOK와 달리 FSK에서 비트를 가르는 양은 무엇인가? 두 톤이 함께 옮겨 가면 판정 문턱을 어떻게 두어야 하는가?

활동 3. 비트 에너지를 줄이면 오류가 얼마나 늘어나는가

같은 송신 전력에서 비트가 짧아지면 왜 더 자주 틀릴까? 비트 에너지는 Eb=PTbE_b=PT_b이다. 전력을 낮추거나 비트 시간을 줄이면 모두 비트에 모이는 에너지가 줄어든다. 여기서 P는 0과 1을 같은 확률로 보낼 때의 평균 신호 전력이다. 표본 하나의 복소 잡음 전력이 σ2\sigma^2이고 한 비트에 W표본을 모으면 Eb/N0=(P/σ2)WE_b/N_0=(P/\sigma^2)W이다.

활동 1에서 쓴 decode_ook로 받은 프레임을 판정한다. 도우미는 헤더로 시작을 찾고, 내 함수가 돌려준 페이로드를 보낸 비트와 비교한다. 켜진 구간과 쉼의 전력 차로 신호 전력을 잰다. 켜짐 전력에서 잡음 전력을 뺀 값의 절반을 P로 쓴다. 순간 주파수로 판정하는 FSK는 앞 활동에서 확인한다.

Pb≈12e−Eb/(2N0)P_b \approx \tfrac12 e^{-E_b/(2N_0)}

이 식은 평균 비트 에너지를 쓰는 비동기 OOK의 근사 이론이다. 먼저 감쇠를 바꾸어 찍은 점과 이론 곡선을 비교한다. 다음에는 같은 감쇠에서 비트당 표본 수 samples를 줄인다. 받기를 누를 때마다 표와 그림에 점이 쌓인다. 수신 전용에서는 강의실 송신 Pluto가 ook를 보내는지 확인한다. 송신 조건을 바꿀 수 없으므로 손잡이 없이 반복 측정한다.

ber_rows = []


async def draw_ber(samples=250, attenuation=72):
    global ber_result
    ber_result = await modlab.observe_ber(
        sdr,
        MODE,
        ber_rows,
        decode_ook,
        samples,
        attenuation,
    )


controls = {}
if MODE == 'loopback':
    controls['samples'] = show.choice([250, 100, 50], value=250, label='비트당 표본')
    controls['attenuation'] = show.slider(64, 80, step=0.25, value=72, unit='dB', label='감쇠')



await show.control(draw_ber, capture=True, **controls)

합성 장치에서는 아래 감쇠 범위에서 BER 약 10−310^{-3}부터 0.2까지 비교한다.

표본/비트 감쇠 범위 (dB)
250 72–80
100 66–76
50 64–74

오류가 드물면 짧은 캡처의 BER은 크게 흔들린다. 오류가 없으면 표에만 남기고 로그 그림에는 점을 찍지 않는다. 프레임을 찾지 못하면 이전 표와 그림 옆에 안내가 나온다.

감쇠를 늘린 점과 비트당 표본을 줄인 점은 Eb/N0E_b/N_0 축에서 어떻게 움직였는가? 오류 수와 수신 상태를 함께 설명한다.