""" Lab022. Совместное действие AWGN и частотного рассогласования. Программа многократно передаёт BPSK-радиокадр из Lab018. В каждой попытке случайно выбираются: - задержка начала кадра; - начальный фазовый поворот; - частотное рассогласование; - реализация AWGN-шума. Сравниваются два варианта приёма: 1. Только компенсация постоянной фазы. 2. Компенсация постоянной фазы и CFO (Carrier Frequency Offset). Для каждой комбинации Eb/N0 и максимального CFO рассчитываются: - FER без компенсации CFO; - FER после компенсации CFO; - число необнаруженных кадров; - средняя корреляционная оценка; - средняя и среднеквадратичная ошибка оценки CFO. """ from csv import DictWriter from pathlib import Path import struct import matplotlib.pyplot as plt import numpy as np from numpy.lib.stride_tricks import sliding_window_view from scipy.signal import fftconvolve from protocol.packet import ( CRCError, MESSAGE_TYPE_TEXT, PacketError, parse_packet, ) # ============================================================ # Параметры радиокадра # ============================================================ RADIO_SYNC_WORD = 0xD391 PREAMBLE_BIT_COUNT = 64 RADIO_FRAME_BIT_COUNT = 320 SAMPLES_PER_SYMBOL = 32 SYMBOL_RATE = 20_000 SAMPLE_RATE = ( SYMBOL_RATE * SAMPLES_PER_SYMBOL ) RRC_ROLLOFF = 0.35 RRC_SPAN_SYMBOLS = 10 # ============================================================ # Контрольные данные внутреннего пакета # ============================================================ EXPECTED_MESSAGE = "ПРИВЕТ SDR" EXPECTED_SEQUENCE_NUMBER = 18 # ============================================================ # Настройки эксперимента # ============================================================ EB_N0_VALUES_DB = [ 6.0, 7.0, 8.0, 10.0, ] # В каждой попытке реальный CFO выбирается случайно # из диапазона: # # -MAX_ABS_CFO ... +MAX_ABS_CFO MAX_ABS_CFO_VALUES_HZ = [ 0.0, 250.0, 1000.0, ] TRIALS_PER_POINT = 30 RANDOM_SEED = 2026 MAX_RANDOM_DELAY_SAMPLES = ( 2 * SAMPLES_PER_SYMBOL ) # При меньшей оценке маркер считаем необнаруженным. DETECTION_THRESHOLD = 0.45 # После грубой оценки CFO выполняется уточняющий поиск. CFO_REFINEMENT_HALF_WIDTH_HZ = 300.0 CFO_REFINEMENT_STEP_HZ = 1.0 MAX_PROTOCOL_PACKET_SIZE = 4096 # ============================================================ # Пути # ============================================================ INPUT_IQ_PATH = Path( "data/processed/lab018/" "lab018_bpsk_tx_iq.npy" ) OUTPUT_DIRECTORY = Path( "data/processed/lab022" ) OUTPUT_DIRECTORY.mkdir( parents=True, exist_ok=True, ) CSV_PATH = ( OUTPUT_DIRECTORY / "lab022_noise_cfo_results.csv" ) GRAPH_PATH = ( OUTPUT_DIRECTORY / "lab022_noise_cfo.png" ) REPORT_PATH = ( OUTPUT_DIRECTORY / "lab022_noise_cfo_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, ) ) 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-фильтра. """ 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]: """ Сформировать известный маркер радиокадра. """ 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 # ============================================================ # Искажения канала # ============================================================ def apply_frequency_offset( iq_samples: np.ndarray, frequency_offset_hz: float, phase_offset_radians: float, ) -> np.ndarray: """ Добавить начальную фазу и частотное рассогласование. """ sample_indexes = np.arange( len(iq_samples), dtype=np.float64, ) phase_values = ( phase_offset_radians + 2.0 * np.pi * frequency_offset_hz * sample_indexes / SAMPLE_RATE ) return ( iq_samples * np.exp( 1j * phase_values ) ) 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 для заданного Eb/N0. """ eb_n0_linear = 10.0 ** ( eb_n0_db / 10.0 ) complex_noise_variance = ( signal_energy_per_bit / eb_n0_linear ) component_sigma = np.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 # ============================================================ # Поиск кадра и оценка CFO # ============================================================ def find_frame_and_frequency_offset( matched_iq: np.ndarray, marker_symbols: np.ndarray, ) -> dict: """ Найти кадр и оценить частотное рассогласование. Алгоритм состоит из двух этапов. Этап 1: грубая оценка CFO по межсимвольному изменению фазы известного маркера. Этап 2: уточняющий частотный поиск около полученной грубой оценки. """ marker_length = len( marker_symbols ) marker_indexes = np.arange( marker_length, dtype=np.float64, ) 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) < marker_length: continue windows = sliding_window_view( symbol_samples, marker_length, ) # Удаляем известные знаки BPSK-маркера. despread_windows = ( windows * marker_symbols[ np.newaxis, : ] ) adjacent_products = ( despread_windows[:, 1:] * np.conj( despread_windows[:, :-1] ) ) coarse_phase_increments = np.angle( np.sum( adjacent_products, axis=1, ) ) coarse_compensation = np.exp( -1j * coarse_phase_increments[ :, np.newaxis ] * marker_indexes[ np.newaxis, : ] ) coherent_sums = np.sum( despread_windows * coarse_compensation, axis=1, ) window_energy = np.sum( np.abs(windows) ** 2, axis=1, ) scores = ( np.abs(coherent_sums) / ( np.sqrt( window_energy * marker_energy ) + 1e-12 ) ) start_index = int( np.argmax( scores ) ) score = float( scores[start_index] ) if ( best_result is None or score > best_result["score"] ): best_result = { "score": score, "sample_phase": sample_phase, "start_symbol_index": start_index, "symbol_samples": symbol_samples, "despread_marker": ( despread_windows[ start_index ].copy() ), "coarse_phase_increment": float( coarse_phase_increments[ start_index ] ), } if best_result is None: raise RuntimeError( "Не удалось выполнить поиск радиокадра" ) # -------------------------------------------------------- # Уточнение CFO частотным поиском # -------------------------------------------------------- coarse_frequency_hz = ( best_result[ "coarse_phase_increment" ] * SYMBOL_RATE / (2.0 * np.pi) ) frequency_candidates_hz = np.arange( ( coarse_frequency_hz - CFO_REFINEMENT_HALF_WIDTH_HZ ), ( coarse_frequency_hz + CFO_REFINEMENT_HALF_WIDTH_HZ + CFO_REFINEMENT_STEP_HZ / 2.0 ), CFO_REFINEMENT_STEP_HZ, dtype=np.float64, ) phase_increment_candidates = ( 2.0 * np.pi * frequency_candidates_hz / SYMBOL_RATE ) candidate_compensation = np.exp( -1j * phase_increment_candidates[ :, np.newaxis ] * marker_indexes[ np.newaxis, : ] ) refined_coherent_sums = np.sum( best_result[ "despread_marker" ][ np.newaxis, : ] * candidate_compensation, axis=1, ) best_frequency_index = int( np.argmax( np.abs( refined_coherent_sums ) ) ) estimated_frequency_hz = float( frequency_candidates_hz[ best_frequency_index ] ) estimated_phase_increment = float( phase_increment_candidates[ best_frequency_index ] ) estimated_initial_phase = float( np.angle( refined_coherent_sums[ best_frequency_index ] ) ) selected_marker_energy = float( np.sum( np.abs( best_result[ "despread_marker" ] ) ** 2 ) ) refined_score = float( np.abs( refined_coherent_sums[ best_frequency_index ] ) / ( np.sqrt( selected_marker_energy * marker_length ) + 1e-12 ) ) best_result.update( { "score": refined_score, "frequency_offset_hz": ( estimated_frequency_hz ), "phase_increment": ( estimated_phase_increment ), "initial_phase": ( estimated_initial_phase ), } ) return best_result # ============================================================ # Разбор восстановленного радиокадра # ============================================================ def decode_radio_frame( corrected_symbols: np.ndarray, frame_start_symbol: int, ) -> str: """ Демодулировать радиокадр и вернуть статус. """ received_bits = bpsk_demodulate( corrected_symbols ) available_bits = received_bits[ frame_start_symbol: ] radio_header_start = ( PREAMBLE_BIT_COUNT ) radio_header_end = ( radio_header_start + 32 ) if len(available_bits) < radio_header_end: return STATUS_HEADER_ERROR 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_HEADER_ERROR if received_radio_sync != RADIO_SYNC_WORD: return STATUS_HEADER_ERROR if not ( 1 <= protocol_packet_length <= MAX_PROTOCOL_PACKET_SIZE ): return STATUS_HEADER_ERROR 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_HEADER_ERROR try: protocol_packet = bits_to_bytes( available_bits[ protocol_packet_start: protocol_packet_end ] ) parsed_packet = parse_packet( protocol_packet ) except CRCError: return STATUS_CRC_ERROR except (PacketError, ValueError): return STATUS_PACKET_ERROR try: restored_message = ( parsed_packet.payload.decode( "utf-8" ) ) except UnicodeDecodeError: return STATUS_PACKET_ERROR if ( parsed_packet.message_type != MESSAGE_TYPE_TEXT or parsed_packet.sequence_number != EXPECTED_SEQUENCE_NUMBER or restored_message != EXPECTED_MESSAGE ): return STATUS_PACKET_ERROR return STATUS_SUCCESS # ============================================================ # Загрузка IQ-сигнала # ============================================================ if not INPUT_IQ_PATH.exists(): raise FileNotFoundError( f"Не найден IQ-файл: {INPUT_IQ_PATH}. " "Сначала необходимо выполнить Lab018." ) transmitted_iq = np.load( INPUT_IQ_PATH ).astype( np.complex128 ) if transmitted_iq.ndim != 1: raise ValueError( "IQ-массив должен быть одномерным" ) if not np.iscomplexobj( transmitted_iq ): raise ValueError( "IQ-массив должен быть комплексным" ) # ============================================================ # Подготовка приёмника # ============================================================ 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_db in EB_N0_VALUES_DB: for max_abs_cfo_hz in ( MAX_ABS_CFO_VALUES_HZ ): random_generator = ( np.random.default_rng( RANDOM_SEED + int( eb_n0_db * 100 ) + int( max_abs_cfo_hz ) ) ) success_without_cfo = 0 success_with_cfo = 0 detection_fail_count = 0 correlation_scores = [] frequency_errors_hz = [] for trial_index in range( TRIALS_PER_POINT ): if max_abs_cfo_hz == 0.0: true_frequency_hz = 0.0 else: true_frequency_hz = float( random_generator.uniform( -max_abs_cfo_hz, max_abs_cfo_hz, ) ) phase_offset_radians = float( random_generator.uniform( -np.pi, np.pi, ) ) sample_delay = int( random_generator.integers( 0, MAX_RANDOM_DELAY_SAMPLES + 1, ) ) impaired_iq = apply_frequency_offset( iq_samples=transmitted_iq, frequency_offset_hz=( true_frequency_hz ), phase_offset_radians=( phase_offset_radians ), ) clean_received_iq = np.concatenate( [ np.zeros( sample_delay, dtype=np.complex128, ), impaired_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 ), ) matched_iq = fftconvolve( noisy_received_iq, rrc_taps, mode="full", ) search_result = ( find_frame_and_frequency_offset( matched_iq=matched_iq, marker_symbols=marker_symbols, ) ) correlation_score = ( search_result["score"] ) correlation_scores.append( correlation_score ) if ( correlation_score < DETECTION_THRESHOLD ): detection_fail_count += 1 continue symbol_samples = search_result[ "symbol_samples" ] frame_start_symbol = search_result[ "start_symbol_index" ] estimated_frequency_hz = ( search_result[ "frequency_offset_hz" ] ) frequency_errors_hz.append( estimated_frequency_hz - true_frequency_hz ) estimated_initial_phase = ( search_result[ "initial_phase" ] ) estimated_phase_increment = ( search_result[ "phase_increment" ] ) symbol_indexes = np.arange( len(symbol_samples), dtype=np.float64, ) relative_symbol_indexes = ( symbol_indexes - frame_start_symbol ) # ----------------------------------------------- # Только постоянная фазовая коррекция # ----------------------------------------------- constant_phase_corrected = ( symbol_samples * np.exp( -1j * estimated_initial_phase ) ) status_without = decode_radio_frame( corrected_symbols=( constant_phase_corrected ), frame_start_symbol=( frame_start_symbol ), ) if status_without == STATUS_SUCCESS: success_without_cfo += 1 # ----------------------------------------------- # Фазовая и частотная коррекция # ----------------------------------------------- complete_phase_model = ( estimated_initial_phase + estimated_phase_increment * relative_symbol_indexes ) frequency_corrected = ( symbol_samples * np.exp( -1j * complete_phase_model ) ) status_with = decode_radio_frame( corrected_symbols=( frequency_corrected ), frame_start_symbol=( frame_start_symbol ), ) if status_with == STATUS_SUCCESS: success_with_cfo += 1 fer_without_cfo = ( 1.0 - success_without_cfo / TRIALS_PER_POINT ) fer_with_cfo = ( 1.0 - success_with_cfo / TRIALS_PER_POINT ) mean_correlation = float( np.mean( correlation_scores ) ) if frequency_errors_hz: frequency_errors_array = np.array( frequency_errors_hz, dtype=np.float64, ) mean_abs_frequency_error_hz = float( np.mean( np.abs( frequency_errors_array ) ) ) frequency_rmse_hz = float( np.sqrt( np.mean( frequency_errors_array ** 2 ) ) ) else: mean_abs_frequency_error_hz = float( "nan" ) frequency_rmse_hz = float( "nan" ) results.append( { "eb_n0_db": eb_n0_db, "max_abs_cfo_hz": ( max_abs_cfo_hz ), "trials": TRIALS_PER_POINT, "success_without_cfo": ( success_without_cfo ), "success_with_cfo": ( success_with_cfo ), "detection_fail_count": ( detection_fail_count ), "fer_without_cfo": ( fer_without_cfo ), "fer_with_cfo": ( fer_with_cfo ), "mean_correlation": ( mean_correlation ), "mean_abs_frequency_error_hz": ( mean_abs_frequency_error_hz ), "frequency_rmse_hz": ( frequency_rmse_hz ), } ) # ============================================================ # Вывод таблицы # ============================================================ print( "=== Lab022. Шум и частотное рассогласование ===" ) print("\nПопыток на каждую точку:") print(TRIALS_PER_POINT) print("\nРезультаты:") print( f"{'Eb/N0':>9}" f"{'CFO ±':>10}" f"{'Успех без':>12}" f"{'Успех с':>11}" f"{'FER без':>10}" f"{'FER с':>10}" f"{'Нет кадра':>11}" f"{'CFO RMSE':>12}" f"{'Коррел.':>10}" ) print("-" * 95) for result in results: print( f"{result['eb_n0_db']:>6.1f} дБ" f"{result['max_abs_cfo_hz']:>7.0f} Гц" f"{result['success_without_cfo']:>12}" f"{result['success_with_cfo']:>11}" f"{result['fer_without_cfo']:>10.3f}" f"{result['fer_with_cfo']:>10.3f}" f"{result['detection_fail_count']:>11}" f"{result['frequency_rmse_hz']:>9.2f} Гц" f"{result['mean_correlation']:>10.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) # ============================================================ # Графики # ============================================================ figure, axes = plt.subplots( 2, 2, figsize=(13, 10), ) # ------------------------------------------------------------ # 1. FER без CFO-компенсации # ------------------------------------------------------------ measurement_floor = ( 0.5 / TRIALS_PER_POINT ) for max_abs_cfo_hz in ( MAX_ABS_CFO_VALUES_HZ ): selected_results = [ result for result in results if ( result["max_abs_cfo_hz"] == max_abs_cfo_hz ) ] eb_values = [ result["eb_n0_db"] for result in selected_results ] fer_values = [ max( result["fer_without_cfo"], measurement_floor, ) for result in selected_results ] axes[0, 0].semilogy( eb_values, fer_values, marker="o", label=( f"CFO ±{max_abs_cfo_hz:.0f} Гц" ), ) axes[0, 0].set_xlabel( "Eb/N0, дБ" ) axes[0, 0].set_ylabel( "FER" ) axes[0, 0].set_title( "FER без компенсации CFO" ) axes[0, 0].grid( True, which="both", ) axes[0, 0].legend() # ------------------------------------------------------------ # 2. FER после CFO-компенсации # ------------------------------------------------------------ for max_abs_cfo_hz in ( MAX_ABS_CFO_VALUES_HZ ): selected_results = [ result for result in results if ( result["max_abs_cfo_hz"] == max_abs_cfo_hz ) ] eb_values = [ result["eb_n0_db"] for result in selected_results ] fer_values = [ max( result["fer_with_cfo"], measurement_floor, ) for result in selected_results ] axes[0, 1].semilogy( eb_values, fer_values, marker="s", label=( f"CFO ±{max_abs_cfo_hz:.0f} Гц" ), ) axes[0, 1].set_xlabel( "Eb/N0, дБ" ) axes[0, 1].set_ylabel( "FER" ) axes[0, 1].set_title( "FER после компенсации CFO" ) axes[0, 1].grid( True, which="both", ) axes[0, 1].legend() # ------------------------------------------------------------ # 3. Ошибка оценки CFO # ------------------------------------------------------------ for max_abs_cfo_hz in ( MAX_ABS_CFO_VALUES_HZ ): selected_results = [ result for result in results if ( result["max_abs_cfo_hz"] == max_abs_cfo_hz ) ] axes[1, 0].plot( [ result["eb_n0_db"] for result in selected_results ], [ result["frequency_rmse_hz"] for result in selected_results ], marker="o", label=( f"CFO ±{max_abs_cfo_hz:.0f} Гц" ), ) axes[1, 0].set_xlabel( "Eb/N0, дБ" ) axes[1, 0].set_ylabel( "CFO RMSE, Гц" ) axes[1, 0].set_title( "Среднеквадратичная ошибка оценки CFO" ) axes[1, 0].grid( True ) axes[1, 0].legend() # ------------------------------------------------------------ # 4. Корреляционная оценка # ------------------------------------------------------------ for max_abs_cfo_hz in ( MAX_ABS_CFO_VALUES_HZ ): selected_results = [ result for result in results if ( result["max_abs_cfo_hz"] == max_abs_cfo_hz ) ] axes[1, 1].plot( [ result["eb_n0_db"] for result in selected_results ], [ result["mean_correlation"] for result in selected_results ], marker="o", label=( f"CFO ±{max_abs_cfo_hz:.0f} Гц" ), ) axes[1, 1].axhline( DETECTION_THRESHOLD, linestyle=":", label="Порог обнаружения", ) axes[1, 1].set_xlabel( "Eb/N0, дБ" ) axes[1, 1].set_ylabel( "Средняя корреляция" ) axes[1, 1].set_title( "Качество обнаружения маркера" ) axes[1, 1].grid( True ) axes[1, 1].legend() figure.tight_layout() figure.savefig( GRAPH_PATH, dpi=160, ) plt.close( figure ) # ============================================================ # Текстовый отчёт # ============================================================ report_lines = [ "Lab022. AWGN and carrier frequency offset", "", ( "Trials per point: " f"{TRIALS_PER_POINT}" ), ( "Detection threshold: " f"{DETECTION_THRESHOLD:.3f}" ), "", ] for result in results: report_lines.extend( [ ( "Eb/N0: " f"{result['eb_n0_db']:.1f} dB" ), ( "Maximum absolute CFO: " f"{result['max_abs_cfo_hz']:.1f} Hz" ), ( "Success without CFO correction: " f"{result['success_without_cfo']}" ), ( "Success with CFO correction: " f"{result['success_with_cfo']}" ), ( "FER without correction: " f"{result['fer_without_cfo']:.6f}" ), ( "FER with correction: " f"{result['fer_with_cfo']:.6f}" ), ( "CFO RMSE: " f"{result['frequency_rmse_hz']:.3f} Hz" ), "", ] ) REPORT_PATH.write_text( "\n".join( report_lines ), encoding="utf-8", ) # ============================================================ # Автоматические проверки # ============================================================ result_10_db_no_cfo = next( result for result in results if ( result["eb_n0_db"] == 10.0 and result["max_abs_cfo_hz"] == 0.0 ) ) result_10_db_large_cfo = next( result for result in results if ( result["eb_n0_db"] == 10.0 and result["max_abs_cfo_hz"] == 1000.0 ) ) total_success_without_large_cfo = sum( result["success_without_cfo"] for result in results if result["max_abs_cfo_hz"] == 1000.0 ) total_success_with_large_cfo = sum( result["success_with_cfo"] for result in results if result["max_abs_cfo_hz"] == 1000.0 ) assert len(results) == ( len(EB_N0_VALUES_DB) * len(MAX_ABS_CFO_VALUES_HZ) ) assert ( result_10_db_no_cfo["success_with_cfo"] >= 25 ) assert ( result_10_db_large_cfo["success_with_cfo"] >= 24 ) assert ( total_success_with_large_cfo > total_success_without_large_cfo ) 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Проверка пройдена: " "совместное действие шума и CFO исследовано." )