""" 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." )