REMI Lab강의 노트

Wireless Embedded Systems E09

실습 D. 변조와 복조 시뮬레이션

비트가 파형이 되어 잡음을 지나 다시 비트가 될 때, 동기가 맞는 BPSK와 Gray QPSK의 BER은 Eb/N0로 정해진다.

이 실습의 자리: 시뮬레이션 — 실습 C에서 스펙트럼을 읽었고, 여기서는 송수신 사슬을 코드로 만든다. 다음 연결 점검에서는 Pluto로 실제 표본을 주고받을 준비를 한다.

준비

활동은 셋이다. 송수신 사슬을 함수로 쓰고, 잡음 손잡이로 BER을 관찰한 뒤, 파일럿으로 일정한 위상 오차를 추정하는 함수를 쓴다. 입력과 잡음은 모두 합성이며 Pluto는 쓰지 않는다. 셀은 실습 A의 준비처럼 위에서부터 실행한다.

이론은 4장 비트와 심볼, Raised cosine 펄스, 정합 필터와 판정을 따른다.

그림과 표 옆의 기록에 넣기로 결과를 담고, 글 상자의 설명과 함께 도구 막대의 저장으로 저장한다.

활동 1. 비트에서 파형을 거쳐 다시 비트로

정해 둔 비트 에너지에 맞추려면 표본마다 얼마의 잡음을 더해야 할까? dsp.bits_to_symbols는 BPSK에서 비트의 부호를, Gray QPSK에서 차례로 I와 Q의 부호를 정한다. 심볼 에너지는 Es=1E_s=1이고 심볼당 비트 수 kk는 각각 1과 2이다. 업샘플링은 심볼 사이에 영을 넣는 일이다. 여기에 RRC 펄스를 컨볼루션하면 심볼마다 같은 펄스를 옮겨 더한 송신 파형이 된다.

RRC는 root-raised-cosine 펄스이며 송신과 수신에 나누어 쓰는 필터이다. 에너지를 ∑nh[n]2=1\sum_n h[n]^2=1로 맞춘다. 펄스를 완만하게 하면 빠른 변화가 줄어 대역이 제한되지만, 유한 길이로 잘랐으므로 이상적인 대역 밖에도 성분이 남는다. 송신 RRC만 지난 파형은 아직 Nyquist 펄스가 아니어서 심볼 시각에도 이웃 심볼의 영향이 남는다.

AWGN(additive white Gaussian noise)은 표본마다 독립인 가우스 잡음을 더하는 모형이다. Eb=Es/kE_b=E_s/k이므로 다음 분산의 복소 잡음을 더한다.

N0=1k 10(Eb/N0)dB/10,w[n]=N0/2 (u[n]+jv[n]),u,v∼N(0,1).N_0=\frac{1}{k\,10^{(E_b/N_0)_{\rm dB}/10}},\qquad w[n]=\sqrt{N_0/2}\,(u[n]+jv[n]),\quad u,v\sim\mathcal N(0,1).

I와 Q의 분산은 각각 N0/2N_0/2이다. 심볼 에너지는 samples_per_symbol개 표본에 퍼지므로 표본 신호 전력은 약 Es/spsE_s/\mathrm{sps}이다. 따라서 파형 전체 대역에서 잰 표본 SNR은 Es/N0E_s/N_0보다 10log⁡10sps10\log_{10}\mathrm{sps} dB 낮다. 이 값과 심볼 시각의 SNR을 혼동하지 않는다.

수신기의 정합 필터(matched filter)는 송신 펄스의 켤레를 시간 반전한 필터이다. 여기서는 실수 대칭 RRC이므로 같은 pulse를 쓴다. 두 필터의 지연을 합친 len(pulse) - 1표본을 건너뛰고 samples_per_symbol표본마다 읽는다. 이 시각에 읽어야 이웃 심볼의 영향이 작다. 여기서는 시각과 반송파 동기가 정확하다고 가정한다.

아래는 공통 펄스를 준비한다. 심볼당 표본은 8개, 펄스 길이는 8심볼, roll-off는 0.35이다. 펄스는 65탭이고 송수신 필터를 합친 지연은 64표본이다.

import math

samples_per_symbol = 8  # 표본/심볼
pulse_span = 8  # 심볼
rolloff = 0.35
pulse = dsp.rrc(rolloff, samples_per_symbol, pulse_span)
filter_delay = len(pulse) - 1  # 표본

print(
    f'탭 {len(pulse)}, 에너지 {np.sum(pulse ** 2):.6f}, 두 필터 지연 {filter_delay}표본'
)


def theory_ber(ebn0_db):
    """E_b/N_0(dB)를 받아 BPSK와 Gray QPSK의 이론 BER을 돌려준다."""
    return 0.5 * math.erfc(math.sqrt(10 ** (ebn0_db / 10)))

쓸 함수 link(modulation, ebn0_db, bit_count)

  • 입력: modulation는 'bpsk' 또는 'qpsk', ebn0_db는 비트 에너지 대 잡음 밀도 비 (dB), bit_count는 양의 짝수 비트 수
  • 출력: (errors, received_symbols, sent_symbols) — 틀린 비트 수, 정합 필터 뒤 심볼 시각의 복소 표본 배열, 보낸 복소 심볼 배열. 두 배열 길이는 bit_count / k이며 무차원이다.
  • 공통값: 위 셀의 samples_per_symbol, pulse, filter_delay. 비교를 재현할 수 있도록 함수 안의 난수 시드는 고정한다.

계산 순서

  1. generator = np.random.default_rng(1)을 만들고 generator.integers(0, 2, bit_count)로 비트를 만든다. dsp.bits_to_symbols(bits, modulation)로 심볼 sent_symbols를 만들고 변조에 맞는 k를 고른다.
  2. dsp.upsample(sent_symbols, samples_per_symbol)와 np.convolve(..., pulse)로 송신 파형 transmitted를 만든다.
  3. 위 식의 noise_density를 구한다. generator.standard_normal(transmitted.size)를 I와 Q에 각각 호출하고 np.sqrt(noise_density / 2)를 곱해 transmitted에 더한다.
  4. np.convolve(received, pulse)로 정합 필터를 적용한다. [filter_delay::samples_per_symbol][:sent_symbols.size]로 심볼 시각의 표본 received_symbols를 읽는다.
  5. dsp.symbols_to_bits(received_symbols, modulation)로 비트를 판정한다. np.sum으로 원래 비트와 다른 자리 수를 세어 int로 바꾸고 received_symbols, sent_symbols와 함께 돌려준다.
def link(modulation, ebn0_db, bit_count):
    """변조, E_b/N_0(dB), 비트 수를 받아 오류 수, 수신 표본, 송신 심볼을 돌려준다."""
    # TODO: 위의 계산 순서대로 link 함수를 구현하세요.
    raise NotImplementedError('link 함수를 구현하세요.')

아래 셀은 6 dB에서 각각 20만 비트를 보낸다. BPSK와 Gray QPSK의 이론 BER은 약 0.00239이다. 유한 비트로 잰 BER은 정확히 같지는 않으므로, 이론 오류 수에서 이항 분포 표준편차의 다섯 배 이내인지 확인한다. QPSK 성상도의 점 무리는 기준점 둘레에 놓여야 한다.

check_bit_count = 200_000
check_ebn0_db = 6  # dB
expected_ber = theory_ber(check_ebn0_db)
link_results = {}

for modulation in ('bpsk', 'qpsk'):
    errors, received_symbols, sent_symbols = link(modulation, check_ebn0_db, check_bit_count)
    link_results[modulation] = errors

    tolerance = 5 * np.sqrt(check_bit_count * expected_ber * (1 - expected_ber))
    correct = abs(errors - check_bit_count * expected_ber) < tolerance
    print(
        f'{modulation}: {errors} / {check_bit_count}, '
        f'BER {errors / check_bit_count:.5f}, 이론 {expected_ber:.5f}',
        '→ 맞음' if correct else '→ 다시 본다',
    )

show.constellation(
    received_symbols[:2000],
    ref=np.unique(sent_symbols),
    title='합성: 정합 필터 뒤 QPSK',
)

활동 2. 잡음을 바꾸며 BER 읽기

같은 비트 에너지에서 QPSK와 BPSK는 왜 같은 BER을 따를까? Gray QPSK의 I와 Q는 각각 BPSK처럼 부호로 판정한다. 잡음에 견주어 비트 에너지가 커지면 판정 경계를 넘는 일이 드물어진다. BPSK와 Gray QPSK는 Eb/N0E_b/N_0에 대해 같은 이론 곡선 Pb=Q(2Eb/N0)P_b=Q(\sqrt{2E_b/N_0}) 위에 놓인다. 여기서 Q(x)Q(x)는 표준 정규분포가 xx보다 클 확률이다. theory_ber는 이 확률을 math.erfc로 계산한다.

먼저 손잡이로 잡음을 바꾸면서 이론 곡선과 누적된 두 변조의 측정점을 비교한다. 그다음 오류 수와 성상도를 본다. 기록에서 지우기를 고르면 측정점이 지워진다. 누적으로 돌아오면 현재 손잡이 값부터 다시 모은다. 각 상태는 같은 난수로 만든 20만 비트를 쓴다. 오류가 드문 쪽에서는 작은 오류 수 차이가 BER을 크게 흔든다. 오류가 없는 측정은 BER이 영임을 뜻하지 않으므로 로그축에는 점을 찍지 않고 표에 오류 수를 남긴다.

from sdrlab import simviews

ber_view = simviews.BerSweep(link, theory_ber)
show.control(
    ber_view.draw,
    ebn0_db=show.slider(0, 9, step=1, value=6, unit='dB', label='E_b/N_0'),
    기록=show.choice(['누적', '지우기'], value='누적', label='기록'),
)

잡음이 줄면 성상도와 BER 점은 어떻게 달라지는가? 오류가 적게 나온 상태의 BER을 읽을 때 왜 보낸 비트 수도 보아야 하는가?

활동 3. 파일럿으로 위상 추정하기

성상도 전체가 같은 각만큼 돌았다면 어떻게 되돌릴까? 4장 복조에서 받은 복소 기저대역은 위상 오차 θ\theta에 의해 zejθz e^{j\theta}가 된다. 점 무리가 이웃 사분면으로 넘어가면 잡음이 작아도 판정이 틀린다. 파일럿(pilot)은 송수신기가 미리 약속해 수신기도 값을 아는 심볼이다. 받은 파일럿 ypy_p에 보낸 파일럿 sps_p의 켤레를 곱하면 심볼 자체의 위상이 지워져 채널의 회전이 남는다.

θ^=arg⁡(∑pypsp‾),yfixed=y e−jθ^.\hat\theta=\arg\left(\sum_p y_p\overline{s_p}\right),\qquad y_{\rm fixed}=y\,e^{-j\hat\theta}.

여러 파일럿을 더하면 잡음의 영향이 줄어든다. 여기서는 일정한 위상 오차만 보정하며, 시간에 따라 회전이 쌓이는 주파수 오차는 넣지 않는다.

쓸 함수 estimate_phase(received, pilot)

  • 입력: 같은 길이 P의 복소 배열 received와 pilot, 받은 파일럿과 알려진 송신 파일럿 (무차원)
  • 출력: 추정 위상 estimated_phase (rad), np.angle이 돌려주는 주값

계산 순서

  1. np.asarray로 입력을 배열로 바꾼다.
  2. np.conj(pilot)을 received에 곱하고 np.sum으로 더한다.
  3. 합의 각을 np.angle로 구해 돌려준다.
def estimate_phase(received, pilot):
    """알려진 파일럿으로 일정한 회전각(rad)을 추정한다."""
    # TODO: 위의 계산 순서대로 estimate_phase 함수를 구현하세요.
    raise NotImplementedError('estimate_phase 함수를 구현하세요.')

아래는 10 dB QPSK에 60° 회전을 넣고 처음 2048심볼을 파일럿으로 쓴다. 추정 각은 넣은 각에서 3° 이내이고, 보정 뒤 비트 오류는 보정 전보다 줄어야 한다. 먼저 보정 전의 점과 기준점을 보고, 그다음 보정 뒤의 점이 기준점 둘레로 돌아왔는지 본다. 잡음과 유한 길이 필터 때문에 보정 뒤 모든 점이 기준점과 정확히 같아지는 것은 아니다.

phase_errors, phase_received, phase_symbols = link('qpsk', 10, 20_000)
sent_bits = dsp.symbols_to_bits(phase_symbols, 'qpsk')
rotation_degrees = 60  # 도
rotated_symbols = phase_received * np.exp(1j * np.deg2rad(rotation_degrees))

estimated_phase = estimate_phase(rotated_symbols[:2048], phase_symbols[:2048])
corrected_symbols = rotated_symbols * np.exp(-1j * estimated_phase)
errors_before = int(np.sum(dsp.symbols_to_bits(rotated_symbols, 'qpsk') != sent_bits))
errors_after = int(np.sum(dsp.symbols_to_bits(corrected_symbols, 'qpsk') != sent_bits))

print(f'추정 {np.degrees(estimated_phase):.2f}°, 오류 {errors_before} → {errors_after}')
correct = abs(np.degrees(estimated_phase) - rotation_degrees) < 3 and errors_after < errors_before
print('→ 맞음' if correct else '→ 다시 본다')

show.constellation(
    rotated_symbols[:2000],
    ref=np.unique(phase_symbols),
    title='합성: 위상 보정 전',
)
show.constellation(
    corrected_symbols[:2000],
    ref=np.unique(phase_symbols),
    title='합성: 파일럿으로 보정한 뒤',
)

알려진 파일럿의 켤레를 곱하면 무엇이 남는가? 보정 전후 성상도의 변화로 설명한다.