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>
1213 lines
25 KiB
Python
1213 lines
25 KiB
Python
"""
|
||
Lab019. Программный приёмник BPSK-радиокадра.
|
||
|
||
Программа:
|
||
|
||
1. Загружает IQ-сэмплы, созданные в Lab018.
|
||
2. Намеренно добавляет неизвестную задержку.
|
||
3. Намеренно поворачивает фазу сигнала.
|
||
4. Выполняет согласованную RRC-фильтрацию.
|
||
5. Перебирает все возможные фазы дискретизации.
|
||
6. Ищет PREAMBLE + RADIO SYNC корреляционным методом.
|
||
7. Оценивает фазовый поворот BPSK.
|
||
8. Восстанавливает биты радиокадра.
|
||
9. Читает длину внутреннего пакета.
|
||
10. Разбирает пакет SDR Rover Link.
|
||
11. Проверяет CRC-32.
|
||
12. Восстанавливает исходное сообщение.
|
||
|
||
Упрощения:
|
||
|
||
- частота передатчика и приёмника совпадает;
|
||
- частота дискретизации совпадает;
|
||
- доплеровский сдвиг отсутствует;
|
||
- многолучёвость отсутствует;
|
||
- шум пока не добавляется.
|
||
"""
|
||
|
||
from pathlib import Path
|
||
import struct
|
||
|
||
import matplotlib.pyplot as plt
|
||
import numpy as np
|
||
|
||
from protocol.packet import (
|
||
MESSAGE_TYPE_TEXT,
|
||
parse_packet,
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Настройки радиокадра
|
||
# ============================================================
|
||
|
||
RADIO_SYNC_WORD = 0xD391
|
||
|
||
PREAMBLE_BIT_COUNT = 64
|
||
|
||
SAMPLES_PER_SYMBOL = 32
|
||
|
||
RRC_ROLLOFF = 0.35
|
||
RRC_SPAN_SYMBOLS = 10
|
||
|
||
|
||
# ============================================================
|
||
# Контрольные данные
|
||
# ============================================================
|
||
|
||
EXPECTED_MESSAGE = "ПРИВЕТ SDR"
|
||
EXPECTED_SEQUENCE_NUMBER = 18
|
||
|
||
|
||
# ============================================================
|
||
# Искусственные искажения для проверки приёмника
|
||
# ============================================================
|
||
|
||
# Приёмнику заранее неизвестно,
|
||
# с какого конкретно сэмпла начинается сигнал.
|
||
TEST_SAMPLE_DELAY = 11
|
||
|
||
# Приёмнику также заранее неизвестна
|
||
# фаза несущей BPSK.
|
||
TEST_PHASE_OFFSET_DEGREES = 37.0
|
||
|
||
|
||
# ============================================================
|
||
# Пути
|
||
# ============================================================
|
||
|
||
INPUT_IQ_PATH = Path(
|
||
"data/processed/lab018/"
|
||
"lab018_bpsk_tx_iq.npy"
|
||
)
|
||
|
||
OUTPUT_DIRECTORY = Path(
|
||
"data/processed/lab019"
|
||
)
|
||
|
||
OUTPUT_DIRECTORY.mkdir(
|
||
parents=True,
|
||
exist_ok=True,
|
||
)
|
||
|
||
GRAPH_PATH = (
|
||
OUTPUT_DIRECTORY
|
||
/ "lab019_bpsk_receiver.png"
|
||
)
|
||
|
||
REPORT_PATH = (
|
||
OUTPUT_DIRECTORY
|
||
/ "lab019_receiver_report.txt"
|
||
)
|
||
|
||
RECOVERED_PACKET_PATH = (
|
||
OUTPUT_DIRECTORY
|
||
/ "lab019_recovered_packet.bin"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Преобразование bytes → bits
|
||
# ============================================================
|
||
|
||
def bytes_to_bits(
|
||
data: bytes,
|
||
) -> np.ndarray:
|
||
"""
|
||
Преобразовать bytes в массив битов 0 и 1.
|
||
"""
|
||
|
||
if not isinstance(data, bytes):
|
||
raise TypeError(
|
||
"data должен иметь тип bytes"
|
||
)
|
||
|
||
byte_array = np.frombuffer(
|
||
data,
|
||
dtype=np.uint8,
|
||
)
|
||
|
||
return np.unpackbits(
|
||
byte_array
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Преобразование 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
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# BPSK-демодулятор
|
||
# ============================================================
|
||
|
||
def bpsk_demodulate(
|
||
symbols: np.ndarray,
|
||
) -> np.ndarray:
|
||
"""
|
||
Принять решение по знаку компоненты I.
|
||
|
||
I < 0 → 0
|
||
I >= 0 → 1
|
||
"""
|
||
|
||
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.
|
||
"""
|
||
|
||
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
|
||
|
||
|
||
# ============================================================
|
||
# Формирование известного маркера
|
||
# ============================================================
|
||
|
||
def build_frame_marker(
|
||
) -> tuple[np.ndarray, np.ndarray]:
|
||
"""
|
||
Сформировать:
|
||
|
||
PREAMBLE + RADIO SYNC
|
||
|
||
Возвращает биты и BPSK-символы маркера.
|
||
"""
|
||
|
||
preamble_bits = np.tile(
|
||
np.array(
|
||
[1, 0],
|
||
dtype=np.uint8,
|
||
),
|
||
PREAMBLE_BIT_COUNT // 2,
|
||
)
|
||
|
||
sync_bytes = struct.pack(
|
||
">H",
|
||
RADIO_SYNC_WORD,
|
||
)
|
||
|
||
sync_bits = bytes_to_bits(
|
||
sync_bytes
|
||
)
|
||
|
||
marker_bits = np.concatenate(
|
||
[
|
||
preamble_bits,
|
||
sync_bits,
|
||
]
|
||
)
|
||
|
||
marker_symbols = bpsk_modulate(
|
||
marker_bits
|
||
)
|
||
|
||
return marker_bits, marker_symbols
|
||
|
||
|
||
# ============================================================
|
||
# Корреляционный поиск радиокадра
|
||
# ============================================================
|
||
|
||
def find_radio_frame(
|
||
matched_iq: np.ndarray,
|
||
marker_symbols: np.ndarray,
|
||
samples_per_symbol: int,
|
||
) -> dict:
|
||
"""
|
||
Найти фазу дискретизации и начало радиокадра.
|
||
|
||
Для каждой возможной фазы:
|
||
|
||
0, 1, 2, ... SPS - 1
|
||
|
||
берём по одному сэмплу на символ и вычисляем
|
||
нормированную корреляцию с известным маркером.
|
||
|
||
Использование комплексной корреляции позволяет
|
||
одновременно оценить постоянный фазовый поворот.
|
||
"""
|
||
|
||
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",
|
||
)
|
||
|
||
denominator = (
|
||
np.sqrt(
|
||
window_energy
|
||
* marker_energy
|
||
)
|
||
+ 1e-12
|
||
)
|
||
|
||
normalized_correlation = (
|
||
np.abs(correlation)
|
||
/ denominator
|
||
)
|
||
|
||
start_symbol_index = int(
|
||
np.argmax(
|
||
normalized_correlation
|
||
)
|
||
)
|
||
|
||
correlation_score = float(
|
||
normalized_correlation[
|
||
start_symbol_index
|
||
]
|
||
)
|
||
|
||
complex_correlation = correlation[
|
||
start_symbol_index
|
||
]
|
||
|
||
if (
|
||
best_result is None
|
||
or correlation_score
|
||
> best_result["score"]
|
||
):
|
||
best_result = {
|
||
"score": correlation_score,
|
||
"sample_phase": sample_phase,
|
||
"start_symbol_index": (
|
||
start_symbol_index
|
||
),
|
||
"symbol_samples": (
|
||
symbol_samples
|
||
),
|
||
"correlation": correlation,
|
||
"normalized_correlation": (
|
||
normalized_correlation
|
||
),
|
||
"complex_correlation": (
|
||
complex_correlation
|
||
),
|
||
}
|
||
|
||
if best_result is None:
|
||
raise RuntimeError(
|
||
"Не удалось выполнить поиск радиокадра"
|
||
)
|
||
|
||
return best_result
|
||
|
||
|
||
# ============================================================
|
||
# Загрузка IQ из Lab018
|
||
# ============================================================
|
||
|
||
if not INPUT_IQ_PATH.exists():
|
||
raise FileNotFoundError(
|
||
f"Не найден IQ-файл: {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(
|
||
"Файл не содержит комплексные IQ-сэмплы"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Имитация неизвестных параметров канала
|
||
# ============================================================
|
||
|
||
phase_offset_radians = np.deg2rad(
|
||
TEST_PHASE_OFFSET_DEGREES
|
||
)
|
||
|
||
phase_rotated_iq = (
|
||
transmitted_iq
|
||
* np.exp(
|
||
1j * phase_offset_radians
|
||
)
|
||
)
|
||
|
||
delayed_iq = np.concatenate(
|
||
[
|
||
np.zeros(
|
||
TEST_SAMPLE_DELAY,
|
||
dtype=np.complex64,
|
||
),
|
||
phase_rotated_iq.astype(
|
||
np.complex64
|
||
),
|
||
]
|
||
)
|
||
|
||
received_iq = delayed_iq
|
||
|
||
|
||
# ============================================================
|
||
# Согласованный RRC-фильтр
|
||
# ============================================================
|
||
|
||
rrc_taps = root_raised_cosine_taps(
|
||
rolloff=RRC_ROLLOFF,
|
||
samples_per_symbol=SAMPLES_PER_SYMBOL,
|
||
span_symbols=RRC_SPAN_SYMBOLS,
|
||
)
|
||
|
||
matched_iq = np.convolve(
|
||
received_iq,
|
||
rrc_taps,
|
||
mode="full",
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Поиск PREAMBLE + RADIO SYNC
|
||
# ============================================================
|
||
|
||
marker_bits, marker_symbols = (
|
||
build_frame_marker()
|
||
)
|
||
|
||
search_result = find_radio_frame(
|
||
matched_iq=matched_iq,
|
||
marker_symbols=marker_symbols,
|
||
samples_per_symbol=SAMPLES_PER_SYMBOL,
|
||
)
|
||
|
||
correlation_score = search_result[
|
||
"score"
|
||
]
|
||
|
||
selected_sample_phase = search_result[
|
||
"sample_phase"
|
||
]
|
||
|
||
frame_start_symbol = search_result[
|
||
"start_symbol_index"
|
||
]
|
||
|
||
symbol_samples_before_correction = (
|
||
search_result["symbol_samples"]
|
||
)
|
||
|
||
complex_correlation = search_result[
|
||
"complex_correlation"
|
||
]
|
||
|
||
|
||
# ============================================================
|
||
# Оценка и компенсация фазы
|
||
# ============================================================
|
||
|
||
estimated_phase_radians = np.angle(
|
||
complex_correlation
|
||
)
|
||
|
||
estimated_phase_degrees = np.rad2deg(
|
||
estimated_phase_radians
|
||
)
|
||
|
||
phase_correction = np.exp(
|
||
-1j * estimated_phase_radians
|
||
)
|
||
|
||
corrected_symbol_samples = (
|
||
symbol_samples_before_correction
|
||
* phase_correction
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Демодуляция
|
||
# ============================================================
|
||
|
||
all_received_bits = bpsk_demodulate(
|
||
corrected_symbol_samples
|
||
)
|
||
|
||
available_frame_bits = all_received_bits[
|
||
frame_start_symbol:
|
||
]
|
||
|
||
|
||
# ============================================================
|
||
# Проверка преамбулы
|
||
# ============================================================
|
||
|
||
received_marker_bits = available_frame_bits[
|
||
:len(marker_bits)
|
||
]
|
||
|
||
marker_bit_errors = int(
|
||
np.count_nonzero(
|
||
received_marker_bits
|
||
!= marker_bits
|
||
)
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Чтение радиозаголовка
|
||
# ============================================================
|
||
|
||
RADIO_HEADER_BIT_COUNT = 32
|
||
|
||
radio_header_start = (
|
||
PREAMBLE_BIT_COUNT
|
||
)
|
||
|
||
radio_header_end = (
|
||
radio_header_start
|
||
+ RADIO_HEADER_BIT_COUNT
|
||
)
|
||
|
||
if len(available_frame_bits) < radio_header_end:
|
||
raise RuntimeError(
|
||
"Недостаточно битов для чтения радиозаголовка"
|
||
)
|
||
|
||
received_radio_header_bits = (
|
||
available_frame_bits[
|
||
radio_header_start:
|
||
radio_header_end
|
||
]
|
||
)
|
||
|
||
received_radio_header = bits_to_bytes(
|
||
received_radio_header_bits
|
||
)
|
||
|
||
(
|
||
received_radio_sync,
|
||
protocol_packet_length,
|
||
) = struct.unpack(
|
||
">HH",
|
||
received_radio_header,
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Извлечение внутреннего пакета
|
||
# ============================================================
|
||
|
||
if received_radio_sync != RADIO_SYNC_WORD:
|
||
raise RuntimeError(
|
||
"Получено неверное радиосинхрослово: "
|
||
f"0x{received_radio_sync:04X}"
|
||
)
|
||
|
||
protocol_packet_bit_count = (
|
||
protocol_packet_length * 8
|
||
)
|
||
|
||
protocol_packet_start = (
|
||
radio_header_end
|
||
)
|
||
|
||
protocol_packet_end = (
|
||
protocol_packet_start
|
||
+ protocol_packet_bit_count
|
||
)
|
||
|
||
if len(available_frame_bits) < protocol_packet_end:
|
||
raise RuntimeError(
|
||
"Принятый радиокадр короче значения LENGTH"
|
||
)
|
||
|
||
received_protocol_packet_bits = (
|
||
available_frame_bits[
|
||
protocol_packet_start:
|
||
protocol_packet_end
|
||
]
|
||
)
|
||
|
||
received_protocol_packet = bits_to_bytes(
|
||
received_protocol_packet_bits
|
||
)
|
||
|
||
RECOVERED_PACKET_PATH.write_bytes(
|
||
received_protocol_packet
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Разбор внутреннего пакета
|
||
# ============================================================
|
||
|
||
parsed_packet = parse_packet(
|
||
received_protocol_packet
|
||
)
|
||
|
||
restored_message = (
|
||
parsed_packet.payload.decode(
|
||
"utf-8"
|
||
)
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Вывод
|
||
# ============================================================
|
||
|
||
print(
|
||
"=== Lab019. Программный BPSK-приёмник ==="
|
||
)
|
||
|
||
print("\nВходной IQ-файл:")
|
||
|
||
print(INPUT_IQ_PATH)
|
||
|
||
print("\nКоличество входных IQ-сэмплов:")
|
||
|
||
print(len(transmitted_iq))
|
||
|
||
print("\nИскусственно внесено:")
|
||
|
||
print(
|
||
"Задержка:",
|
||
TEST_SAMPLE_DELAY,
|
||
"сэмплов",
|
||
)
|
||
|
||
print(
|
||
"Поворот фазы:",
|
||
TEST_PHASE_OFFSET_DEGREES,
|
||
"градусов",
|
||
)
|
||
|
||
|
||
print("\nРезультат синхронизации:")
|
||
|
||
print(
|
||
"Найденная фаза дискретизации:",
|
||
selected_sample_phase,
|
||
"из",
|
||
SAMPLES_PER_SYMBOL,
|
||
)
|
||
|
||
print(
|
||
"Начало кадра в потоке символов:",
|
||
frame_start_symbol,
|
||
)
|
||
|
||
print(
|
||
"Корреляционная оценка:",
|
||
f"{correlation_score:.6f}",
|
||
)
|
||
|
||
print(
|
||
"Оценённый фазовый поворот:",
|
||
f"{estimated_phase_degrees:.2f}",
|
||
"градуса",
|
||
)
|
||
|
||
print(
|
||
"Ошибки в PREAMBLE + SYNC:",
|
||
marker_bit_errors,
|
||
)
|
||
|
||
|
||
print("\nРадиозаголовок:")
|
||
|
||
print(
|
||
"RADIO SYNC:",
|
||
f"0x{received_radio_sync:04X}",
|
||
)
|
||
|
||
print(
|
||
"LENGTH:",
|
||
protocol_packet_length,
|
||
"байт",
|
||
)
|
||
|
||
|
||
print("\nВнутренний пакет:")
|
||
|
||
print(
|
||
"Версия:",
|
||
parsed_packet.version,
|
||
)
|
||
|
||
print(
|
||
"Тип сообщения:",
|
||
parsed_packet.message_type,
|
||
)
|
||
|
||
print(
|
||
"Порядковый номер:",
|
||
parsed_packet.sequence_number,
|
||
)
|
||
|
||
print(
|
||
"Размер PAYLOAD:",
|
||
len(parsed_packet.payload),
|
||
"байт",
|
||
)
|
||
|
||
|
||
print("\nВосстановленное сообщение:")
|
||
|
||
print(restored_message)
|
||
|
||
|
||
print("\nВосстановленный бинарный пакет:")
|
||
|
||
print(RECOVERED_PACKET_PATH)
|
||
|
||
|
||
# ============================================================
|
||
# Графики
|
||
# ============================================================
|
||
|
||
figure, axes = plt.subplots(
|
||
2,
|
||
2,
|
||
figsize=(13, 10),
|
||
)
|
||
|
||
|
||
# ------------------------------------------------------------
|
||
# 1. Корреляция
|
||
# ------------------------------------------------------------
|
||
|
||
normalized_correlation = search_result[
|
||
"normalized_correlation"
|
||
]
|
||
|
||
axes[0, 0].plot(
|
||
normalized_correlation
|
||
)
|
||
|
||
axes[0, 0].axvline(
|
||
frame_start_symbol,
|
||
linestyle="--",
|
||
)
|
||
|
||
axes[0, 0].set_xlabel(
|
||
"Предполагаемое начало маркера, символ"
|
||
)
|
||
|
||
axes[0, 0].set_ylabel(
|
||
"Нормированная корреляция"
|
||
)
|
||
|
||
axes[0, 0].set_title(
|
||
"Поиск PREAMBLE + RADIO SYNC"
|
||
)
|
||
|
||
axes[0, 0].grid(
|
||
True
|
||
)
|
||
|
||
|
||
# ------------------------------------------------------------
|
||
# 2. Созвездие до коррекции
|
||
# ------------------------------------------------------------
|
||
|
||
marker_start = frame_start_symbol
|
||
|
||
marker_end = (
|
||
marker_start
|
||
+ len(marker_symbols)
|
||
)
|
||
|
||
marker_samples_before = (
|
||
symbol_samples_before_correction[
|
||
marker_start:
|
||
marker_end
|
||
]
|
||
)
|
||
|
||
axes[0, 1].scatter(
|
||
marker_samples_before.real,
|
||
marker_samples_before.imag,
|
||
s=20,
|
||
)
|
||
|
||
axes[0, 1].axhline(
|
||
0.0,
|
||
linewidth=1,
|
||
)
|
||
|
||
axes[0, 1].axvline(
|
||
0.0,
|
||
linewidth=1,
|
||
)
|
||
|
||
axes[0, 1].set_xlabel(
|
||
"I"
|
||
)
|
||
|
||
axes[0, 1].set_ylabel(
|
||
"Q"
|
||
)
|
||
|
||
axes[0, 1].set_title(
|
||
"BPSK до компенсации фазы"
|
||
)
|
||
|
||
axes[0, 1].axis(
|
||
"equal"
|
||
)
|
||
|
||
axes[0, 1].grid(
|
||
True
|
||
)
|
||
|
||
|
||
# ------------------------------------------------------------
|
||
# 3. Созвездие после коррекции
|
||
# ------------------------------------------------------------
|
||
|
||
marker_samples_after = (
|
||
corrected_symbol_samples[
|
||
marker_start:
|
||
marker_end
|
||
]
|
||
)
|
||
|
||
axes[1, 0].scatter(
|
||
marker_samples_after.real,
|
||
marker_samples_after.imag,
|
||
s=20,
|
||
)
|
||
|
||
axes[1, 0].axhline(
|
||
0.0,
|
||
linewidth=1,
|
||
)
|
||
|
||
axes[1, 0].axvline(
|
||
0.0,
|
||
linewidth=1,
|
||
)
|
||
|
||
axes[1, 0].set_xlabel(
|
||
"I"
|
||
)
|
||
|
||
axes[1, 0].set_ylabel(
|
||
"Q"
|
||
)
|
||
|
||
axes[1, 0].set_title(
|
||
"BPSK после компенсации фазы"
|
||
)
|
||
|
||
axes[1, 0].axis(
|
||
"equal"
|
||
)
|
||
|
||
axes[1, 0].grid(
|
||
True
|
||
)
|
||
|
||
|
||
# ------------------------------------------------------------
|
||
# 4. Восстановленные биты кадра
|
||
# ------------------------------------------------------------
|
||
|
||
received_frame_bits = available_frame_bits[
|
||
:protocol_packet_end
|
||
]
|
||
|
||
axes[1, 1].step(
|
||
np.arange(
|
||
len(received_frame_bits)
|
||
),
|
||
received_frame_bits,
|
||
where="post",
|
||
)
|
||
|
||
axes[1, 1].axvline(
|
||
PREAMBLE_BIT_COUNT,
|
||
linestyle="--",
|
||
)
|
||
|
||
axes[1, 1].axvline(
|
||
radio_header_end,
|
||
linestyle="--",
|
||
)
|
||
|
||
axes[1, 1].set_xlabel(
|
||
"Номер бита"
|
||
)
|
||
|
||
axes[1, 1].set_ylabel(
|
||
"Бит"
|
||
)
|
||
|
||
axes[1, 1].set_title(
|
||
"Восстановленный радиокадр"
|
||
)
|
||
|
||
axes[1, 1].set_ylim(
|
||
-0.2,
|
||
1.2,
|
||
)
|
||
|
||
axes[1, 1].grid(
|
||
True
|
||
)
|
||
|
||
|
||
figure.tight_layout()
|
||
|
||
figure.savefig(
|
||
GRAPH_PATH,
|
||
dpi=160,
|
||
)
|
||
|
||
plt.close(
|
||
figure
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Отчёт
|
||
# ============================================================
|
||
|
||
report_lines = [
|
||
"Lab019. BPSK receiver",
|
||
"",
|
||
f"Input IQ: {INPUT_IQ_PATH}",
|
||
f"Artificial sample delay: {TEST_SAMPLE_DELAY}",
|
||
(
|
||
"Artificial phase offset: "
|
||
f"{TEST_PHASE_OFFSET_DEGREES:.2f} deg"
|
||
),
|
||
(
|
||
"Detected sample phase: "
|
||
f"{selected_sample_phase}"
|
||
),
|
||
(
|
||
"Detected frame start symbol: "
|
||
f"{frame_start_symbol}"
|
||
),
|
||
(
|
||
"Correlation score: "
|
||
f"{correlation_score:.6f}"
|
||
),
|
||
(
|
||
"Estimated phase: "
|
||
f"{estimated_phase_degrees:.2f} deg"
|
||
),
|
||
(
|
||
"Marker bit errors: "
|
||
f"{marker_bit_errors}"
|
||
),
|
||
(
|
||
"Radio sync: "
|
||
f"0x{received_radio_sync:04X}"
|
||
),
|
||
(
|
||
"Protocol packet length: "
|
||
f"{protocol_packet_length} bytes"
|
||
),
|
||
(
|
||
"Sequence number: "
|
||
f"{parsed_packet.sequence_number}"
|
||
),
|
||
f"Restored message: {restored_message}",
|
||
]
|
||
|
||
REPORT_PATH.write_text(
|
||
"\n".join(
|
||
report_lines
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Автоматические проверки
|
||
# ============================================================
|
||
|
||
assert received_radio_sync == RADIO_SYNC_WORD
|
||
|
||
assert protocol_packet_length == len(
|
||
received_protocol_packet
|
||
)
|
||
|
||
assert marker_bit_errors == 0
|
||
|
||
assert parsed_packet.message_type == (
|
||
MESSAGE_TYPE_TEXT
|
||
)
|
||
|
||
assert parsed_packet.sequence_number == (
|
||
EXPECTED_SEQUENCE_NUMBER
|
||
)
|
||
|
||
assert restored_message == EXPECTED_MESSAGE
|
||
|
||
assert abs(
|
||
estimated_phase_degrees
|
||
- TEST_PHASE_OFFSET_DEGREES
|
||
) < 1.0
|
||
|
||
assert correlation_score > 0.95
|
||
|
||
assert GRAPH_PATH.exists()
|
||
assert REPORT_PATH.exists()
|
||
assert RECOVERED_PACKET_PATH.exists()
|
||
|
||
|
||
print("\nГрафик приёмника:")
|
||
|
||
print(GRAPH_PATH)
|
||
|
||
print("\nОтчёт:")
|
||
|
||
print(REPORT_PATH)
|
||
|
||
print(
|
||
"\nПроверка пройдена: "
|
||
"радиокадр синхронизирован, "
|
||
"BPSK демодулирован, "
|
||
"внутренний пакет восстановлен и прошёл CRC."
|
||
)
|