The tests/ directory held 50 laboratory programs and no tests. They model channels, run hundreds of repetitions and write CSV, PNG and reports; calling that a test suite blocked introducing a real one, because any pytest run would have collected the labs and re-executed every experiment. - move all 50 lab programs to experiments/ with git mv, preserving history - rewrite the 38 cross-imports between labs from tests.labNNN to experiments.labNNN - leave tests/ empty for actual fast checks of protocol/ - point quick_gate and the hook at the new layout and add experiments/ to the syntax sweep - update the paths quoted in the Lab042 specification and the verifier agent definition This also defuses the import-time work finding without touching 41 files: the labs still create directories and write files on import, but nothing imports them now except the gate, which does so deliberately. Gate passes: syntax clean, protocol imports, 15 lab modules import, 2 functional suites run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1494 lines
32 KiB
Python
1494 lines
32 KiB
Python
"""
|
||
Lab020. Frame Error Rate полного BPSK-радиокадра.
|
||
|
||
Программа многократно передаёт IQ-радиокадр из Lab018
|
||
при разных значениях Eb/N0.
|
||
|
||
В каждой попытке случайным образом изменяются:
|
||
|
||
- задержка начала сигнала;
|
||
- постоянный фазовый поворот BPSK;
|
||
- реализация AWGN-шума.
|
||
|
||
Приёмник выполняет:
|
||
|
||
1. Согласованную RRC-фильтрацию.
|
||
2. Поиск PREAMBLE + RADIO SYNC.
|
||
3. Выбор фазы символьной дискретизации.
|
||
4. Оценку и компенсацию фазового поворота.
|
||
5. Демодуляцию BPSK.
|
||
6. Чтение радиозаголовка.
|
||
7. Извлечение внутреннего пакета.
|
||
8. Проверку структуры и CRC-32.
|
||
9. Восстановление исходного сообщения.
|
||
|
||
Результат:
|
||
|
||
FER — Frame Error Rate, доля радиокадров,
|
||
которые не удалось полностью восстановить.
|
||
"""
|
||
|
||
from csv import DictWriter
|
||
from math import erfc, expm1, log1p, sqrt
|
||
from pathlib import Path
|
||
import struct
|
||
|
||
import matplotlib.pyplot as plt
|
||
import numpy as np
|
||
from scipy.signal import fftconvolve
|
||
|
||
from protocol.packet import (
|
||
CRCError,
|
||
MESSAGE_TYPE_TEXT,
|
||
PacketError,
|
||
parse_packet,
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Параметры радиокадра из Lab018
|
||
# ============================================================
|
||
|
||
RADIO_SYNC_WORD = 0xD391
|
||
|
||
PREAMBLE_BIT_COUNT = 64
|
||
|
||
SAMPLES_PER_SYMBOL = 32
|
||
|
||
RRC_ROLLOFF = 0.35
|
||
RRC_SPAN_SYMBOLS = 10
|
||
|
||
# Полный радиокадр:
|
||
#
|
||
# PREAMBLE 64 бита
|
||
# RADIO HEADER 32 бита
|
||
# INNER PACKET 224 бита
|
||
#
|
||
# Итого 320 бит
|
||
RADIO_FRAME_BIT_COUNT = 320
|
||
|
||
|
||
# ============================================================
|
||
# Контрольные данные
|
||
# ============================================================
|
||
|
||
EXPECTED_MESSAGE = "ПРИВЕТ SDR"
|
||
EXPECTED_SEQUENCE_NUMBER = 18
|
||
|
||
|
||
# ============================================================
|
||
# Настройки эксперимента
|
||
# ============================================================
|
||
|
||
EB_N0_VALUES_DB = [
|
||
4.0,
|
||
5.0,
|
||
6.0,
|
||
7.0,
|
||
8.0,
|
||
10.0,
|
||
12.0,
|
||
]
|
||
|
||
TRIALS_PER_EB_N0 = 100
|
||
|
||
RANDOM_SEED = 2026
|
||
|
||
# Максимальная случайная задержка начала кадра.
|
||
MAX_RANDOM_DELAY_SAMPLES = (
|
||
2 * SAMPLES_PER_SYMBOL
|
||
)
|
||
|
||
# Если нормированная корреляция ниже этого значения,
|
||
# считаем, что маркер радиокадра не обнаружен.
|
||
DETECTION_THRESHOLD = 0.55
|
||
|
||
# Защита от повреждённого поля LENGTH.
|
||
MAX_PROTOCOL_PACKET_SIZE = 4096
|
||
|
||
|
||
# ============================================================
|
||
# Пути
|
||
# ============================================================
|
||
|
||
INPUT_IQ_PATH = Path(
|
||
"data/processed/lab018/"
|
||
"lab018_bpsk_tx_iq.npy"
|
||
)
|
||
|
||
OUTPUT_DIRECTORY = Path(
|
||
"data/processed/lab020"
|
||
)
|
||
|
||
OUTPUT_DIRECTORY.mkdir(
|
||
parents=True,
|
||
exist_ok=True,
|
||
)
|
||
|
||
CSV_PATH = (
|
||
OUTPUT_DIRECTORY
|
||
/ "lab020_frame_error_results.csv"
|
||
)
|
||
|
||
GRAPH_PATH = (
|
||
OUTPUT_DIRECTORY
|
||
/ "lab020_frame_error_rate.png"
|
||
)
|
||
|
||
REPORT_PATH = (
|
||
OUTPUT_DIRECTORY
|
||
/ "lab020_frame_error_report.txt"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Статусы одной попытки
|
||
# ============================================================
|
||
|
||
STATUS_SUCCESS = "SUCCESS"
|
||
STATUS_DETECTION_FAIL = "DETECTION_FAIL"
|
||
STATUS_HEADER_ERROR = "HEADER_ERROR"
|
||
STATUS_CRC_ERROR = "CRC_ERROR"
|
||
STATUS_PACKET_ERROR = "PACKET_ERROR"
|
||
|
||
|
||
# ============================================================
|
||
# Преобразование bytes → bits
|
||
# ============================================================
|
||
|
||
def bytes_to_bits(
|
||
data: bytes,
|
||
) -> np.ndarray:
|
||
"""
|
||
Преобразовать bytes в одномерный массив битов.
|
||
"""
|
||
|
||
if not isinstance(data, bytes):
|
||
raise TypeError(
|
||
"data должен иметь тип bytes"
|
||
)
|
||
|
||
return np.unpackbits(
|
||
np.frombuffer(
|
||
data,
|
||
dtype=np.uint8,
|
||
)
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Преобразование bits → bytes
|
||
# ============================================================
|
||
|
||
def bits_to_bytes(
|
||
bits: np.ndarray,
|
||
) -> bytes:
|
||
"""
|
||
Упаковать биты обратно в bytes.
|
||
"""
|
||
|
||
bits = np.asarray(
|
||
bits,
|
||
dtype=np.uint8,
|
||
)
|
||
|
||
if bits.ndim != 1:
|
||
raise ValueError(
|
||
"bits должен быть одномерным массивом"
|
||
)
|
||
|
||
if len(bits) % 8 != 0:
|
||
raise ValueError(
|
||
"Количество битов должно быть кратно восьми"
|
||
)
|
||
|
||
if not np.all(
|
||
(bits == 0) | (bits == 1)
|
||
):
|
||
raise ValueError(
|
||
"bits должен содержать только 0 и 1"
|
||
)
|
||
|
||
return np.packbits(
|
||
bits
|
||
).tobytes()
|
||
|
||
|
||
# ============================================================
|
||
# BPSK
|
||
# ============================================================
|
||
|
||
def bpsk_modulate(
|
||
bits: np.ndarray,
|
||
) -> np.ndarray:
|
||
"""
|
||
Преобразовать биты в опорные BPSK-символы.
|
||
|
||
0 → -1
|
||
1 → +1
|
||
"""
|
||
|
||
bits = np.asarray(
|
||
bits,
|
||
dtype=np.uint8,
|
||
)
|
||
|
||
symbols = (
|
||
2.0 * bits.astype(np.float64)
|
||
- 1.0
|
||
)
|
||
|
||
return symbols.astype(
|
||
np.complex128
|
||
)
|
||
|
||
|
||
def bpsk_demodulate(
|
||
symbols: np.ndarray,
|
||
) -> np.ndarray:
|
||
"""
|
||
Демодулировать BPSK по знаку компоненты I.
|
||
"""
|
||
|
||
return (
|
||
symbols.real >= 0.0
|
||
).astype(np.uint8)
|
||
|
||
|
||
# ============================================================
|
||
# Root Raised Cosine-фильтр
|
||
# ============================================================
|
||
|
||
def root_raised_cosine_taps(
|
||
rolloff: float,
|
||
samples_per_symbol: int,
|
||
span_symbols: int,
|
||
) -> np.ndarray:
|
||
"""
|
||
Рассчитать коэффициенты RRC-фильтра.
|
||
|
||
Параметры совпадают с Lab018 и Lab019.
|
||
"""
|
||
|
||
if not 0.0 < rolloff <= 1.0:
|
||
raise ValueError(
|
||
"rolloff должен находиться в диапазоне 0...1"
|
||
)
|
||
|
||
if samples_per_symbol <= 0:
|
||
raise ValueError(
|
||
"samples_per_symbol должен быть положительным"
|
||
)
|
||
|
||
if span_symbols <= 0:
|
||
raise ValueError(
|
||
"span_symbols должен быть положительным"
|
||
)
|
||
|
||
if span_symbols % 2 != 0:
|
||
raise ValueError(
|
||
"span_symbols должен быть чётным"
|
||
)
|
||
|
||
half_sample_count = (
|
||
span_symbols
|
||
* samples_per_symbol
|
||
// 2
|
||
)
|
||
|
||
sample_indexes = np.arange(
|
||
-half_sample_count,
|
||
half_sample_count + 1,
|
||
dtype=np.float64,
|
||
)
|
||
|
||
time_values = (
|
||
sample_indexes
|
||
/ samples_per_symbol
|
||
)
|
||
|
||
taps = np.zeros_like(
|
||
time_values
|
||
)
|
||
|
||
beta = rolloff
|
||
|
||
for index, time_value in enumerate(
|
||
time_values
|
||
):
|
||
|
||
if np.isclose(
|
||
time_value,
|
||
0.0,
|
||
):
|
||
taps[index] = (
|
||
1.0
|
||
- beta
|
||
+ 4.0 * beta / np.pi
|
||
)
|
||
|
||
continue
|
||
|
||
if np.isclose(
|
||
abs(time_value),
|
||
1.0 / (4.0 * beta),
|
||
):
|
||
taps[index] = (
|
||
beta
|
||
/ np.sqrt(2.0)
|
||
* (
|
||
(
|
||
1.0
|
||
+ 2.0 / np.pi
|
||
)
|
||
* np.sin(
|
||
np.pi
|
||
/ (4.0 * beta)
|
||
)
|
||
+ (
|
||
1.0
|
||
- 2.0 / np.pi
|
||
)
|
||
* np.cos(
|
||
np.pi
|
||
/ (4.0 * beta)
|
||
)
|
||
)
|
||
)
|
||
|
||
continue
|
||
|
||
numerator = (
|
||
np.sin(
|
||
np.pi
|
||
* time_value
|
||
* (1.0 - beta)
|
||
)
|
||
+ (
|
||
4.0
|
||
* beta
|
||
* time_value
|
||
* np.cos(
|
||
np.pi
|
||
* time_value
|
||
* (1.0 + beta)
|
||
)
|
||
)
|
||
)
|
||
|
||
denominator = (
|
||
np.pi
|
||
* time_value
|
||
* (
|
||
1.0
|
||
- (
|
||
4.0
|
||
* beta
|
||
* time_value
|
||
) ** 2
|
||
)
|
||
)
|
||
|
||
taps[index] = (
|
||
numerator
|
||
/ denominator
|
||
)
|
||
|
||
taps /= np.sqrt(
|
||
np.sum(
|
||
taps ** 2
|
||
)
|
||
)
|
||
|
||
return taps
|
||
|
||
|
||
# ============================================================
|
||
# Маркер PREAMBLE + RADIO SYNC
|
||
# ============================================================
|
||
|
||
def build_frame_marker(
|
||
) -> tuple[np.ndarray, np.ndarray]:
|
||
"""
|
||
Сформировать биты и BPSK-символы маркера.
|
||
"""
|
||
|
||
preamble_bits = np.tile(
|
||
np.array(
|
||
[1, 0],
|
||
dtype=np.uint8,
|
||
),
|
||
PREAMBLE_BIT_COUNT // 2,
|
||
)
|
||
|
||
sync_bits = bytes_to_bits(
|
||
struct.pack(
|
||
">H",
|
||
RADIO_SYNC_WORD,
|
||
)
|
||
)
|
||
|
||
marker_bits = np.concatenate(
|
||
[
|
||
preamble_bits,
|
||
sync_bits,
|
||
]
|
||
)
|
||
|
||
marker_symbols = bpsk_modulate(
|
||
marker_bits
|
||
)
|
||
|
||
return marker_bits, marker_symbols
|
||
|
||
|
||
# ============================================================
|
||
# Теоретический BER и идеальная FER
|
||
# ============================================================
|
||
|
||
def theoretical_bpsk_ber(
|
||
eb_n0_db: float,
|
||
) -> float:
|
||
"""
|
||
Теоретический BER когерентной BPSK в AWGN.
|
||
"""
|
||
|
||
eb_n0_linear = 10.0 ** (
|
||
eb_n0_db / 10.0
|
||
)
|
||
|
||
return 0.5 * erfc(
|
||
sqrt(eb_n0_linear)
|
||
)
|
||
|
||
|
||
def ber_to_frame_error_rate(
|
||
ber: float,
|
||
frame_bit_count: int,
|
||
) -> float:
|
||
"""
|
||
Оценить FER при независимых битовых ошибках.
|
||
|
||
FER = 1 - (1 - BER) ** N
|
||
"""
|
||
|
||
if ber == 0.0:
|
||
return 0.0
|
||
|
||
return -expm1(
|
||
frame_bit_count
|
||
* log1p(-ber)
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Добавление AWGN по заданному Eb/N0
|
||
# ============================================================
|
||
|
||
def add_awgn_for_eb_n0(
|
||
clean_iq: np.ndarray,
|
||
signal_energy_per_bit: float,
|
||
eb_n0_db: float,
|
||
random_generator: np.random.Generator,
|
||
) -> np.ndarray:
|
||
"""
|
||
Добавить комплексный AWGN.
|
||
|
||
signal_energy_per_bit рассчитывается по исходному
|
||
IQ-радиокадру как:
|
||
|
||
сумма |IQ|² / количество передаваемых битов
|
||
|
||
Полная комплексная дисперсия шума:
|
||
|
||
noise_variance = Eb / (Eb/N0)
|
||
"""
|
||
|
||
eb_n0_linear = 10.0 ** (
|
||
eb_n0_db / 10.0
|
||
)
|
||
|
||
complex_noise_variance = (
|
||
signal_energy_per_bit
|
||
/ eb_n0_linear
|
||
)
|
||
|
||
component_sigma = sqrt(
|
||
complex_noise_variance / 2.0
|
||
)
|
||
|
||
noise = component_sigma * (
|
||
random_generator.standard_normal(
|
||
len(clean_iq)
|
||
)
|
||
+ 1j
|
||
* random_generator.standard_normal(
|
||
len(clean_iq)
|
||
)
|
||
)
|
||
|
||
return clean_iq + noise
|
||
|
||
|
||
# ============================================================
|
||
# Корреляционный поиск радиокадра
|
||
# ============================================================
|
||
|
||
def find_radio_frame(
|
||
matched_iq: np.ndarray,
|
||
marker_symbols: np.ndarray,
|
||
samples_per_symbol: int,
|
||
) -> dict:
|
||
"""
|
||
Перебрать все возможные фазы символьной дискретизации
|
||
и найти максимальную нормированную корреляцию.
|
||
"""
|
||
|
||
marker_energy = float(
|
||
np.sum(
|
||
np.abs(marker_symbols) ** 2
|
||
)
|
||
)
|
||
|
||
best_result = None
|
||
|
||
for sample_phase in range(
|
||
samples_per_symbol
|
||
):
|
||
|
||
symbol_samples = matched_iq[
|
||
sample_phase::samples_per_symbol
|
||
]
|
||
|
||
if len(symbol_samples) < len(
|
||
marker_symbols
|
||
):
|
||
continue
|
||
|
||
correlation = np.correlate(
|
||
symbol_samples,
|
||
marker_symbols,
|
||
mode="valid",
|
||
)
|
||
|
||
window_energy = np.convolve(
|
||
np.abs(symbol_samples) ** 2,
|
||
np.ones(
|
||
len(marker_symbols)
|
||
),
|
||
mode="valid",
|
||
)
|
||
|
||
normalized_correlation = (
|
||
np.abs(correlation)
|
||
/ (
|
||
np.sqrt(
|
||
window_energy
|
||
* marker_energy
|
||
)
|
||
+ 1e-12
|
||
)
|
||
)
|
||
|
||
start_symbol_index = int(
|
||
np.argmax(
|
||
normalized_correlation
|
||
)
|
||
)
|
||
|
||
score = float(
|
||
normalized_correlation[
|
||
start_symbol_index
|
||
]
|
||
)
|
||
|
||
if (
|
||
best_result is None
|
||
or score > best_result["score"]
|
||
):
|
||
best_result = {
|
||
"score": score,
|
||
"sample_phase": sample_phase,
|
||
"start_symbol_index": (
|
||
start_symbol_index
|
||
),
|
||
"symbol_samples": (
|
||
symbol_samples
|
||
),
|
||
"complex_correlation": (
|
||
correlation[
|
||
start_symbol_index
|
||
]
|
||
),
|
||
}
|
||
|
||
if best_result is None:
|
||
raise RuntimeError(
|
||
"Корреляционный поиск не дал результата"
|
||
)
|
||
|
||
return best_result
|
||
|
||
|
||
# ============================================================
|
||
# Приём одной реализации радиокадра
|
||
# ============================================================
|
||
|
||
def receive_one_frame(
|
||
received_iq: np.ndarray,
|
||
rrc_taps: np.ndarray,
|
||
marker_bits: np.ndarray,
|
||
marker_symbols: np.ndarray,
|
||
) -> dict:
|
||
"""
|
||
Выполнить полный приём одного радиокадра.
|
||
|
||
Возвращает словарь со статусом и диагностикой.
|
||
"""
|
||
|
||
matched_iq = fftconvolve(
|
||
received_iq,
|
||
rrc_taps,
|
||
mode="full",
|
||
)
|
||
|
||
search_result = find_radio_frame(
|
||
matched_iq=matched_iq,
|
||
marker_symbols=marker_symbols,
|
||
samples_per_symbol=SAMPLES_PER_SYMBOL,
|
||
)
|
||
|
||
correlation_score = search_result[
|
||
"score"
|
||
]
|
||
|
||
if correlation_score < DETECTION_THRESHOLD:
|
||
return {
|
||
"status": STATUS_DETECTION_FAIL,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": None,
|
||
}
|
||
|
||
symbol_samples = search_result[
|
||
"symbol_samples"
|
||
]
|
||
|
||
frame_start_symbol = search_result[
|
||
"start_symbol_index"
|
||
]
|
||
|
||
estimated_phase = np.angle(
|
||
search_result[
|
||
"complex_correlation"
|
||
]
|
||
)
|
||
|
||
corrected_symbols = (
|
||
symbol_samples
|
||
* np.exp(
|
||
-1j * estimated_phase
|
||
)
|
||
)
|
||
|
||
received_bits = bpsk_demodulate(
|
||
corrected_symbols
|
||
)
|
||
|
||
available_bits = received_bits[
|
||
frame_start_symbol:
|
||
]
|
||
|
||
marker_bit_count = len(
|
||
marker_bits
|
||
)
|
||
|
||
if len(available_bits) < marker_bit_count:
|
||
return {
|
||
"status": STATUS_HEADER_ERROR,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": None,
|
||
}
|
||
|
||
received_marker_bits = available_bits[
|
||
:marker_bit_count
|
||
]
|
||
|
||
marker_bit_errors = int(
|
||
np.count_nonzero(
|
||
received_marker_bits
|
||
!= marker_bits
|
||
)
|
||
)
|
||
|
||
radio_header_start = (
|
||
PREAMBLE_BIT_COUNT
|
||
)
|
||
|
||
radio_header_end = (
|
||
radio_header_start + 32
|
||
)
|
||
|
||
if len(available_bits) < radio_header_end:
|
||
return {
|
||
"status": STATUS_HEADER_ERROR,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": marker_bit_errors,
|
||
}
|
||
|
||
try:
|
||
radio_header = bits_to_bytes(
|
||
available_bits[
|
||
radio_header_start:
|
||
radio_header_end
|
||
]
|
||
)
|
||
|
||
(
|
||
received_radio_sync,
|
||
protocol_packet_length,
|
||
) = struct.unpack(
|
||
">HH",
|
||
radio_header,
|
||
)
|
||
|
||
except (ValueError, struct.error):
|
||
return {
|
||
"status": STATUS_HEADER_ERROR,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": marker_bit_errors,
|
||
}
|
||
|
||
if received_radio_sync != RADIO_SYNC_WORD:
|
||
return {
|
||
"status": STATUS_HEADER_ERROR,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": marker_bit_errors,
|
||
}
|
||
|
||
if not (
|
||
1
|
||
<= protocol_packet_length
|
||
<= MAX_PROTOCOL_PACKET_SIZE
|
||
):
|
||
return {
|
||
"status": STATUS_HEADER_ERROR,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": marker_bit_errors,
|
||
}
|
||
|
||
protocol_packet_start = (
|
||
radio_header_end
|
||
)
|
||
|
||
protocol_packet_end = (
|
||
protocol_packet_start
|
||
+ protocol_packet_length * 8
|
||
)
|
||
|
||
if len(available_bits) < protocol_packet_end:
|
||
return {
|
||
"status": STATUS_HEADER_ERROR,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": marker_bit_errors,
|
||
}
|
||
|
||
try:
|
||
protocol_packet = bits_to_bytes(
|
||
available_bits[
|
||
protocol_packet_start:
|
||
protocol_packet_end
|
||
]
|
||
)
|
||
|
||
except ValueError:
|
||
return {
|
||
"status": STATUS_PACKET_ERROR,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": marker_bit_errors,
|
||
}
|
||
|
||
try:
|
||
parsed_packet = parse_packet(
|
||
protocol_packet
|
||
)
|
||
|
||
except CRCError:
|
||
return {
|
||
"status": STATUS_CRC_ERROR,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": marker_bit_errors,
|
||
}
|
||
|
||
except PacketError:
|
||
return {
|
||
"status": STATUS_PACKET_ERROR,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": marker_bit_errors,
|
||
}
|
||
|
||
try:
|
||
restored_message = (
|
||
parsed_packet.payload.decode(
|
||
"utf-8"
|
||
)
|
||
)
|
||
|
||
except UnicodeDecodeError:
|
||
return {
|
||
"status": STATUS_PACKET_ERROR,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": marker_bit_errors,
|
||
}
|
||
|
||
if (
|
||
parsed_packet.message_type
|
||
!= MESSAGE_TYPE_TEXT
|
||
or parsed_packet.sequence_number
|
||
!= EXPECTED_SEQUENCE_NUMBER
|
||
or restored_message
|
||
!= EXPECTED_MESSAGE
|
||
):
|
||
return {
|
||
"status": STATUS_PACKET_ERROR,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": marker_bit_errors,
|
||
}
|
||
|
||
return {
|
||
"status": STATUS_SUCCESS,
|
||
"correlation_score": correlation_score,
|
||
"marker_bit_errors": marker_bit_errors,
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# Загрузка IQ из Lab018
|
||
# ============================================================
|
||
|
||
if not INPUT_IQ_PATH.exists():
|
||
raise FileNotFoundError(
|
||
f"Не найден файл: {INPUT_IQ_PATH}. "
|
||
"Сначала необходимо выполнить Lab018."
|
||
)
|
||
|
||
transmitted_iq = np.load(
|
||
INPUT_IQ_PATH
|
||
)
|
||
|
||
if transmitted_iq.ndim != 1:
|
||
raise ValueError(
|
||
"IQ-массив должен быть одномерным"
|
||
)
|
||
|
||
if not np.iscomplexobj(
|
||
transmitted_iq
|
||
):
|
||
raise ValueError(
|
||
"Входной массив должен быть комплексным"
|
||
)
|
||
|
||
transmitted_iq = transmitted_iq.astype(
|
||
np.complex128
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Подготовка приёмника
|
||
# ============================================================
|
||
|
||
rrc_taps = root_raised_cosine_taps(
|
||
rolloff=RRC_ROLLOFF,
|
||
samples_per_symbol=SAMPLES_PER_SYMBOL,
|
||
span_symbols=RRC_SPAN_SYMBOLS,
|
||
)
|
||
|
||
marker_bits, marker_symbols = (
|
||
build_frame_marker()
|
||
)
|
||
|
||
signal_energy_per_bit = (
|
||
np.sum(
|
||
np.abs(transmitted_iq) ** 2
|
||
)
|
||
/ RADIO_FRAME_BIT_COUNT
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Основной эксперимент
|
||
# ============================================================
|
||
|
||
results = []
|
||
|
||
for eb_n0_index, eb_n0_db in enumerate(
|
||
EB_N0_VALUES_DB
|
||
):
|
||
|
||
counters = {
|
||
STATUS_SUCCESS: 0,
|
||
STATUS_DETECTION_FAIL: 0,
|
||
STATUS_HEADER_ERROR: 0,
|
||
STATUS_CRC_ERROR: 0,
|
||
STATUS_PACKET_ERROR: 0,
|
||
}
|
||
|
||
correlation_scores = []
|
||
marker_error_counts = []
|
||
|
||
random_generator = np.random.default_rng(
|
||
RANDOM_SEED
|
||
+ 1000 * eb_n0_index
|
||
)
|
||
|
||
for trial_index in range(
|
||
TRIALS_PER_EB_N0
|
||
):
|
||
|
||
random_delay = int(
|
||
random_generator.integers(
|
||
0,
|
||
MAX_RANDOM_DELAY_SAMPLES + 1,
|
||
)
|
||
)
|
||
|
||
random_phase = (
|
||
random_generator.uniform(
|
||
-np.pi,
|
||
np.pi,
|
||
)
|
||
)
|
||
|
||
phase_rotated_iq = (
|
||
transmitted_iq
|
||
* np.exp(
|
||
1j * random_phase
|
||
)
|
||
)
|
||
|
||
# Добавляем нули до и после кадра.
|
||
# После внесения AWGN эти участки превращаются
|
||
# в обычный шумовой фон.
|
||
clean_received_iq = np.concatenate(
|
||
[
|
||
np.zeros(
|
||
random_delay,
|
||
dtype=np.complex128,
|
||
),
|
||
phase_rotated_iq,
|
||
np.zeros(
|
||
2 * SAMPLES_PER_SYMBOL,
|
||
dtype=np.complex128,
|
||
),
|
||
]
|
||
)
|
||
|
||
noisy_received_iq = add_awgn_for_eb_n0(
|
||
clean_iq=clean_received_iq,
|
||
signal_energy_per_bit=(
|
||
signal_energy_per_bit
|
||
),
|
||
eb_n0_db=eb_n0_db,
|
||
random_generator=random_generator,
|
||
)
|
||
|
||
attempt_result = receive_one_frame(
|
||
received_iq=noisy_received_iq,
|
||
rrc_taps=rrc_taps,
|
||
marker_bits=marker_bits,
|
||
marker_symbols=marker_symbols,
|
||
)
|
||
|
||
status = attempt_result[
|
||
"status"
|
||
]
|
||
|
||
counters[status] += 1
|
||
|
||
correlation_scores.append(
|
||
attempt_result[
|
||
"correlation_score"
|
||
]
|
||
)
|
||
|
||
marker_bit_errors = attempt_result[
|
||
"marker_bit_errors"
|
||
]
|
||
|
||
if marker_bit_errors is not None:
|
||
marker_error_counts.append(
|
||
marker_bit_errors
|
||
)
|
||
|
||
success_count = counters[
|
||
STATUS_SUCCESS
|
||
]
|
||
|
||
frame_error_count = (
|
||
TRIALS_PER_EB_N0
|
||
- success_count
|
||
)
|
||
|
||
experimental_fer = (
|
||
frame_error_count
|
||
/ TRIALS_PER_EB_N0
|
||
)
|
||
|
||
theoretical_ber = theoretical_bpsk_ber(
|
||
eb_n0_db
|
||
)
|
||
|
||
ideal_fer = ber_to_frame_error_rate(
|
||
ber=theoretical_ber,
|
||
frame_bit_count=(
|
||
RADIO_FRAME_BIT_COUNT
|
||
),
|
||
)
|
||
|
||
mean_correlation = float(
|
||
np.mean(
|
||
correlation_scores
|
||
)
|
||
)
|
||
|
||
if marker_error_counts:
|
||
mean_marker_bit_errors = float(
|
||
np.mean(
|
||
marker_error_counts
|
||
)
|
||
)
|
||
else:
|
||
mean_marker_bit_errors = float(
|
||
"nan"
|
||
)
|
||
|
||
results.append(
|
||
{
|
||
"eb_n0_db": eb_n0_db,
|
||
"trials": TRIALS_PER_EB_N0,
|
||
"success_count": success_count,
|
||
"detection_fail_count": counters[
|
||
STATUS_DETECTION_FAIL
|
||
],
|
||
"header_error_count": counters[
|
||
STATUS_HEADER_ERROR
|
||
],
|
||
"crc_error_count": counters[
|
||
STATUS_CRC_ERROR
|
||
],
|
||
"packet_error_count": counters[
|
||
STATUS_PACKET_ERROR
|
||
],
|
||
"experimental_fer": experimental_fer,
|
||
"theoretical_ber": theoretical_ber,
|
||
"ideal_fer": ideal_fer,
|
||
"mean_correlation": mean_correlation,
|
||
"mean_marker_bit_errors": (
|
||
mean_marker_bit_errors
|
||
),
|
||
}
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Вывод таблицы
|
||
# ============================================================
|
||
|
||
print(
|
||
"=== Lab020. FER полного BPSK-радиокадра ==="
|
||
)
|
||
|
||
print("\nКоличество попыток на точку:")
|
||
|
||
print(TRIALS_PER_EB_N0)
|
||
|
||
print("\nРезультаты:")
|
||
|
||
print(
|
||
f"{'Eb/N0':>9}"
|
||
f"{'Успех':>9}"
|
||
f"{'Нет марк.':>11}"
|
||
f"{'Загол.':>9}"
|
||
f"{'CRC':>7}"
|
||
f"{'Пакет':>8}"
|
||
f"{'FER':>10}"
|
||
f"{'Идеал FER':>13}"
|
||
f"{'Коррел.':>11}"
|
||
)
|
||
|
||
print("-" * 87)
|
||
|
||
for result in results:
|
||
|
||
print(
|
||
f"{result['eb_n0_db']:>6.1f} дБ"
|
||
f"{result['success_count']:>9}"
|
||
f"{result['detection_fail_count']:>11}"
|
||
f"{result['header_error_count']:>9}"
|
||
f"{result['crc_error_count']:>7}"
|
||
f"{result['packet_error_count']:>8}"
|
||
f"{result['experimental_fer']:>10.3f}"
|
||
f"{result['ideal_fer']:>13.3f}"
|
||
f"{result['mean_correlation']:>11.3f}"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Сохранение CSV
|
||
# ============================================================
|
||
|
||
with CSV_PATH.open(
|
||
"w",
|
||
newline="",
|
||
encoding="utf-8-sig",
|
||
) as csv_file:
|
||
|
||
writer = DictWriter(
|
||
csv_file,
|
||
fieldnames=list(
|
||
results[0].keys()
|
||
),
|
||
)
|
||
|
||
writer.writeheader()
|
||
writer.writerows(results)
|
||
|
||
|
||
# ============================================================
|
||
# Подготовка данных графика
|
||
# ============================================================
|
||
|
||
eb_n0_values = np.array(
|
||
[
|
||
result["eb_n0_db"]
|
||
for result in results
|
||
],
|
||
dtype=np.float64,
|
||
)
|
||
|
||
experimental_fer_values = np.array(
|
||
[
|
||
result["experimental_fer"]
|
||
for result in results
|
||
],
|
||
dtype=np.float64,
|
||
)
|
||
|
||
ideal_fer_values = np.array(
|
||
[
|
||
result["ideal_fer"]
|
||
for result in results
|
||
],
|
||
dtype=np.float64,
|
||
)
|
||
|
||
measurement_floor = (
|
||
0.5 / TRIALS_PER_EB_N0
|
||
)
|
||
|
||
experimental_fer_for_plot = np.maximum(
|
||
experimental_fer_values,
|
||
measurement_floor,
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Графики
|
||
# ============================================================
|
||
|
||
figure, axes = plt.subplots(
|
||
2,
|
||
1,
|
||
figsize=(11, 10),
|
||
)
|
||
|
||
|
||
# ------------------------------------------------------------
|
||
# 1. FER
|
||
# ------------------------------------------------------------
|
||
|
||
axes[0].semilogy(
|
||
eb_n0_values,
|
||
ideal_fer_values,
|
||
marker="o",
|
||
label=(
|
||
"Идеальная FER по BER "
|
||
"при идеальной синхронизации"
|
||
),
|
||
)
|
||
|
||
axes[0].semilogy(
|
||
eb_n0_values,
|
||
experimental_fer_for_plot,
|
||
marker="s",
|
||
linestyle="--",
|
||
label="Экспериментальная FER приёмника",
|
||
)
|
||
|
||
axes[0].axhline(
|
||
measurement_floor,
|
||
linestyle=":",
|
||
label=(
|
||
"Предел измерения "
|
||
f"{measurement_floor:.3f}"
|
||
),
|
||
)
|
||
|
||
axes[0].set_xlabel(
|
||
"Eb/N0, дБ"
|
||
)
|
||
|
||
axes[0].set_ylabel(
|
||
"Frame Error Rate"
|
||
)
|
||
|
||
axes[0].set_title(
|
||
"Устойчивость полного BPSK-радиокадра"
|
||
)
|
||
|
||
axes[0].grid(
|
||
True,
|
||
which="both",
|
||
)
|
||
|
||
axes[0].legend()
|
||
|
||
|
||
# ------------------------------------------------------------
|
||
# 2. Классификация результатов
|
||
# ------------------------------------------------------------
|
||
|
||
success_rates = [
|
||
result["success_count"]
|
||
/ TRIALS_PER_EB_N0
|
||
for result in results
|
||
]
|
||
|
||
detection_fail_rates = [
|
||
result["detection_fail_count"]
|
||
/ TRIALS_PER_EB_N0
|
||
for result in results
|
||
]
|
||
|
||
header_error_rates = [
|
||
result["header_error_count"]
|
||
/ TRIALS_PER_EB_N0
|
||
for result in results
|
||
]
|
||
|
||
crc_error_rates = [
|
||
result["crc_error_count"]
|
||
/ TRIALS_PER_EB_N0
|
||
for result in results
|
||
]
|
||
|
||
packet_error_rates = [
|
||
result["packet_error_count"]
|
||
/ TRIALS_PER_EB_N0
|
||
for result in results
|
||
]
|
||
|
||
axes[1].plot(
|
||
eb_n0_values,
|
||
success_rates,
|
||
marker="o",
|
||
label="Успешно",
|
||
)
|
||
|
||
axes[1].plot(
|
||
eb_n0_values,
|
||
detection_fail_rates,
|
||
marker="o",
|
||
label="Маркер не обнаружен",
|
||
)
|
||
|
||
axes[1].plot(
|
||
eb_n0_values,
|
||
header_error_rates,
|
||
marker="o",
|
||
label="Ошибка радиозаголовка",
|
||
)
|
||
|
||
axes[1].plot(
|
||
eb_n0_values,
|
||
crc_error_rates,
|
||
marker="o",
|
||
label="Ошибка CRC",
|
||
)
|
||
|
||
axes[1].plot(
|
||
eb_n0_values,
|
||
packet_error_rates,
|
||
marker="o",
|
||
label="Ошибка структуры пакета",
|
||
)
|
||
|
||
axes[1].set_xlabel(
|
||
"Eb/N0, дБ"
|
||
)
|
||
|
||
axes[1].set_ylabel(
|
||
"Доля попыток"
|
||
)
|
||
|
||
axes[1].set_ylim(
|
||
-0.03,
|
||
1.03,
|
||
)
|
||
|
||
axes[1].set_title(
|
||
"Причины потери радиокадров"
|
||
)
|
||
|
||
axes[1].grid(
|
||
True
|
||
)
|
||
|
||
axes[1].legend()
|
||
|
||
|
||
figure.tight_layout()
|
||
|
||
figure.savefig(
|
||
GRAPH_PATH,
|
||
dpi=160,
|
||
)
|
||
|
||
plt.close(
|
||
figure
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Текстовый отчёт
|
||
# ============================================================
|
||
|
||
report_lines = [
|
||
"Lab020. Full BPSK frame error rate",
|
||
"",
|
||
(
|
||
"Trials per Eb/N0: "
|
||
f"{TRIALS_PER_EB_N0}"
|
||
),
|
||
(
|
||
"Detection threshold: "
|
||
f"{DETECTION_THRESHOLD:.3f}"
|
||
),
|
||
(
|
||
"Frame bit count: "
|
||
f"{RADIO_FRAME_BIT_COUNT}"
|
||
),
|
||
"",
|
||
]
|
||
|
||
for result in results:
|
||
|
||
report_lines.extend(
|
||
[
|
||
f"Eb/N0: {result['eb_n0_db']:.1f} dB",
|
||
(
|
||
" Success: "
|
||
f"{result['success_count']}"
|
||
),
|
||
(
|
||
" Detection fail: "
|
||
f"{result['detection_fail_count']}"
|
||
),
|
||
(
|
||
" Header error: "
|
||
f"{result['header_error_count']}"
|
||
),
|
||
(
|
||
" CRC error: "
|
||
f"{result['crc_error_count']}"
|
||
),
|
||
(
|
||
" Packet error: "
|
||
f"{result['packet_error_count']}"
|
||
),
|
||
(
|
||
" Experimental FER: "
|
||
f"{result['experimental_fer']:.6f}"
|
||
),
|
||
(
|
||
" Ideal FER: "
|
||
f"{result['ideal_fer']:.6f}"
|
||
),
|
||
"",
|
||
]
|
||
)
|
||
|
||
REPORT_PATH.write_text(
|
||
"\n".join(
|
||
report_lines
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Автоматические проверки
|
||
# ============================================================
|
||
|
||
result_at_4_db = next(
|
||
result
|
||
for result in results
|
||
if result["eb_n0_db"] == 4.0
|
||
)
|
||
|
||
result_at_12_db = next(
|
||
result
|
||
for result in results
|
||
if result["eb_n0_db"] == 12.0
|
||
)
|
||
|
||
assert sum(
|
||
[
|
||
result[
|
||
"success_count"
|
||
],
|
||
result[
|
||
"detection_fail_count"
|
||
],
|
||
result[
|
||
"header_error_count"
|
||
],
|
||
result[
|
||
"crc_error_count"
|
||
],
|
||
result[
|
||
"packet_error_count"
|
||
],
|
||
]
|
||
) == TRIALS_PER_EB_N0
|
||
|
||
assert (
|
||
result_at_12_db["success_count"]
|
||
> result_at_4_db["success_count"]
|
||
)
|
||
|
||
assert (
|
||
result_at_12_db["experimental_fer"]
|
||
< result_at_4_db["experimental_fer"]
|
||
)
|
||
|
||
assert CSV_PATH.exists()
|
||
assert GRAPH_PATH.exists()
|
||
assert REPORT_PATH.exists()
|
||
|
||
|
||
print("\nCSV:")
|
||
|
||
print(CSV_PATH)
|
||
|
||
print("\nГрафик:")
|
||
|
||
print(GRAPH_PATH)
|
||
|
||
print("\nОтчёт:")
|
||
|
||
print(REPORT_PATH)
|
||
|
||
print(
|
||
"\nПроверка пройдена: "
|
||
"измерена FER полного BPSK-радиокадра."
|
||
) |