"""Lab042: передача JPEG через кабельную петлю на одном PlutoSDR. Первая лабораторная, в которой включается передатчик. За Lab001–Lab041 весь тракт был собран и проверен на моделях, но ни одного вызова передачи через SDR в проекте не было. Схема опыта: выход передатчика → аттенюатор 30 дБ → вход приёмника Передатчик и приёмник принадлежат одному устройству и работают от общего опорного генератора, поэтому ухода несущей между ними практически нет. Разведение на два устройства — предмет Lab043. Лабораторная работает в двух режимах. Программный замыкает канал в памяти и оборудования не требует; он служит эталоном, с которым сравнивается аппаратный прогон. Разность между режимами и есть вклад радиотракта. python -m experiments.lab042_pluto_image_loopback --loopback software python -m experiments.lab042_pluto_image_loopback --loopback hardware Постановка задачи: docs/lab042_hardware_loopback_spec.md """ from __future__ import annotations import argparse import csv import io import time from dataclasses import asdict, dataclass, field from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np from PIL import Image from scipy.signal import fftconvolve from protocol import bpsk_radio as radio from protocol.image_fragments import ( decode_image_fragment, encode_image_fragment, reassemble_image, split_image_bytes, ) from protocol.packet import ( MESSAGE_TYPE_IMAGE_FRAGMENT, CRCError, PacketError, build_packet, parse_packet, ) # ---------------------------------------------------------------- параметры тракта # Значения перенесены из Lab018 без изменений, кроме передискретизации. SYMBOL_RATE = 20_000 RRC_ROLLOFF = 0.35 RRC_SPAN_SYMBOLS = 10 GUARD_SYMBOL_COUNT = 16 TX_AMPLITUDE = 0.70 # Изменено против Lab018. Опрос устройства показал, что минимальная частота # дискретизации AD9361 равна 2 083 333 Гц, поэтому 640 кГц из Lab018 # недостижимы. Символьная скорость и форма сигнала сохранены, изменена # только степень передискретизации — величина стыка с устройством. SAMPLES_PER_SYMBOL = 128 SAMPLE_RATE_HZ = SYMBOL_RATE * SAMPLES_PER_SYMBOL DEVICE_MINIMUM_SAMPLE_RATE_HZ = 2_083_333 # ---------------------------------------------------------------- параметры устройства PLUTO_URI = "ip:192.168.2.1" CARRIER_HZ = 435_000_000 RF_BANDWIDTH_HZ = 200_000 TX_GAIN_DB = -30.0 # Диапазон усиления приёмника получен опросом устройства 10 августа 2026 # года: hardwaregain_available = [-3 1 71] дБ в ручном режиме. RX_GAIN_DB = 20.0 RX_BUFFER_SIZE = 262_144 # Ослабление внешнего аттенюатора AT30S, включённого в разрыв тракта. ATTENUATOR_DB = 30.0 # PlutoSDR принимает целые отсчёты. Множитель приводит сигнал с амплитудой # около единицы к разрядности передающего тракта. SAMPLE_SCALE = 2**14 # Во сколько раз дольше кадра принимается сигнал, чтобы кадр гарантированно # попал в записанный отрезок целиком. RECEIVE_MARGIN = 2.5 # ---------------------------------------------------------------- параметры изображения SOURCE_IMAGE = Path("data/raw/lab011_scene.jpg") IMAGE_WIDTH = 320 IMAGE_HEIGHT = 180 JPEG_QUALITY = 40 TARGET_JPEG_BYTES = 5_000 FRAGMENT_DATA_SIZE = 512 IMAGE_ID = 42 # ---------------------------------------------------------------- выходные файлы OUTPUT_DIRECTORY = Path("data/processed/lab042") RECEIVED_IMAGE_PATH = OUTPUT_DIRECTORY / "lab042_received.jpg" SUMMARY_CSV = OUTPUT_DIRECTORY / "lab042_summary.csv" FRAMES_CSV = OUTPUT_DIRECTORY / "lab042_frames.csv" REPORT_PATH = OUTPUT_DIRECTORY / "lab042_report.txt" PLOT_PATHS = ( OUTPUT_DIRECTORY / "lab042_constellation.png", OUTPUT_DIRECTORY / "lab042_spectrum.png", OUTPUT_DIRECTORY / "lab042_frame_correlation.png", OUTPUT_DIRECTORY / "lab042_fragment_delivery.png", ) @dataclass(frozen=True) class FramePlan: """Один подготовленный к передаче радиокадр.""" index: int packet: bytes bits: np.ndarray symbols: np.ndarray marker: np.ndarray @dataclass(frozen=True) class FrameResult: """Итог приёма одного радиокадра.""" index: int packet_bytes: int frame_bits: int correlation_score: float sample_phase: int carrier_offset_hz: float carrier_coherence: float error_vector_magnitude: float frame_found: bool header_parsed: bool crc_valid: bool fragment_recovered: bool @dataclass(frozen=True) class TransferMetrics: """Сводка одного прогона.""" loopback_mode: str source_image: str image_width: int image_height: int jpeg_quality: int jpeg_bytes: int fragment_data_size: int fragment_count: int packet_bytes_max: int packet_bytes_min: int frame_bits_max: int frame_bits_min: int symbol_rate: int samples_per_symbol: int sample_rate_hz: int carrier_hz: int transmitted_samples: int air_time_seconds: float frames_received: int frames_failed_crc: int fragments_recovered: int fragment_delivery_fraction: float image_reassembled: bool image_identical: bool received_image_bytes: int transfer_seconds: float channel_bitrate_kbps: float wall_clock_bitrate_kbps: float mean_correlation_score: float mean_carrier_offset_hz: float mean_error_vector_magnitude: float tx_gain_db: float rx_gain_db: float attenuator_db: float @dataclass class FunctionalTestResult: """Результат одной функциональной проверки.""" name: str passed: bool detail: str # ==================================================================== подготовка def prepare_jpeg( source_path: Path = SOURCE_IMAGE, width: int = IMAGE_WIDTH, height: int = IMAGE_HEIGHT, quality: int = JPEG_QUALITY, target_bytes: int = TARGET_JPEG_BYTES, ) -> tuple[bytes, int]: """Подготовить изображение по профилю, отобранному в Lab010. Качество понижается шагами, пока размер не уложится в заданный предел. Поэтому итоговое качество является результатом подготовки, а не заранее принятым значением, и возвращается вместе с байтами. Возвращает готовые байты JPEG в градациях серого и применённое качество. """ if not source_path.exists(): raise FileNotFoundError(f"Исходное изображение не найдено: {source_path}") with Image.open(source_path) as image: prepared = image.convert("L").resize((width, height)) for applied_quality in range(quality, 4, -5): buffer = io.BytesIO() prepared.save(buffer, format="JPEG", quality=applied_quality, optimize=True) encoded = buffer.getvalue() if len(encoded) <= target_bytes: return encoded, applied_quality raise ValueError( f"Изображение {width}x{height} не удалось уложить в {target_bytes} байт " f"даже при минимальном качестве" ) def build_frames(jpeg_bytes: bytes, fragment_data_size: int = FRAGMENT_DATA_SIZE) -> list[FramePlan]: """Разбить изображение на фрагменты и обернуть каждый в радиокадр.""" fragments = split_image_bytes(jpeg_bytes, image_id=IMAGE_ID, fragment_data_size=fragment_data_size) plans: list[FramePlan] = [] for index, fragment in enumerate(fragments): packet = build_packet( payload=encode_image_fragment(fragment), message_type=MESSAGE_TYPE_IMAGE_FRAGMENT, sequence_number=index, ) bits, symbols, marker = radio.build_radio_frame(packet) plans.append(FramePlan(index, packet, bits, symbols, marker)) return plans def shaping_filter() -> np.ndarray: """Вернуть коэффициенты формирующего фильтра приподнятого косинуса.""" return radio.root_raised_cosine_taps(RRC_ROLLOFF, SAMPLES_PER_SYMBOL, RRC_SPAN_SYMBOLS) def shape_frame(symbols: np.ndarray, taps: np.ndarray) -> np.ndarray: """Сформировать отсчёты одного кадра с защитными интервалами по краям. Защитный интервал ставится и перед кадром, и после него, как в Lab018. Передний нужен потому, что первые отсчёты передачи недостоверны из-за переходного процесса: без него преамбула попала бы ровно на него. Свёртка выполняется через быстрое преобразование Фурье: при 128 отсчётах на символ фильтр содержит более тысячи отводов, и прямая свёртка заняла бы недопустимо много времени. """ upsampled = np.zeros(len(symbols) * SAMPLES_PER_SYMBOL, dtype=complex) upsampled[::SAMPLES_PER_SYMBOL] = symbols guard = np.zeros(GUARD_SYMBOL_COUNT * SAMPLES_PER_SYMBOL, dtype=complex) guarded = np.concatenate([guard, upsampled, guard]) return fftconvolve(guarded, taps, mode="full") * TX_AMPLITUDE def to_device_samples(waveform: np.ndarray) -> np.ndarray: """Привести отсчёты к целочисленному виду, который принимает PlutoSDR.""" peak = float(np.max(np.abs(waveform))) if waveform.size else 0.0 if peak > 1.0: raise ValueError("Амплитуда сигнала превышает единицу, масштабирование переполнится") return (waveform * SAMPLE_SCALE).astype(np.complex64) # ==================================================================== каналы def software_channel(waveform: np.ndarray) -> np.ndarray: """Замкнуть канал в памяти без участия оборудования. Служит эталоном: всё, что отличает аппаратный прогон от этого, внесено радиотрактом. """ return waveform.copy() def open_device(): """Открыть PlutoSDR и настроить его для кабельной петли. Настройки выбраны так, чтобы приёмник не мог быть повреждён: с аттенюатором 30 дБ в разрыве весь диапазон усиления передатчика безопасен, а начальное значение даёт заведомо чистый уровень. """ import adi device = adi.Pluto(uri=PLUTO_URI) device.sample_rate = int(SAMPLE_RATE_HZ) device.tx_lo = int(CARRIER_HZ) device.rx_lo = int(CARRIER_HZ) device.tx_rf_bandwidth = int(RF_BANDWIDTH_HZ) device.rx_rf_bandwidth = int(RF_BANDWIDTH_HZ) device.tx_hardwaregain_chan0 = float(TX_GAIN_DB) # Автоматическая регулировка меняла бы усиление по ходу приёма и # портила оценку качества сигнала, поэтому режим ручной. device.gain_control_mode_chan0 = "manual" device.rx_hardwaregain_chan0 = float(RX_GAIN_DB) device.rx_buffer_size = int(RX_BUFFER_SIZE) # Циклический буфер повторял бы кадр бесконечно: передача не # заканчивалась бы, а приёмник видел тысячи копий одного кадра. device.tx_cyclic_buffer = False return device def hardware_transfer(device, frame_waveform: np.ndarray) -> np.ndarray: """Передать один кадр и записать отрезок эфира, содержащий его.""" needed_samples = int(len(frame_waveform) * RECEIVE_MARGIN) # Приём запускается до передачи. Первый буфер отбрасывается: он собран # до того, как кадр ушёл в эфир, и его назначение — привести приёмный # тракт в рабочее состояние. Тот же приём использован в Lab024a. device.rx() device.tx_destroy_buffer() device.tx(to_device_samples(frame_waveform)) chunks: list[np.ndarray] = [] collected = 0 while collected < needed_samples: chunk = np.asarray(device.rx(), dtype=np.complex64) chunks.append(chunk) collected += len(chunk) device.tx_destroy_buffer() return np.concatenate(chunks) / SAMPLE_SCALE # ==================================================================== приём def receive_frame(segment: np.ndarray, plan: FramePlan, taps: np.ndarray) -> tuple[FrameResult, bytes | None]: """Найти кадр в отрезке отсчётов и восстановить из него фрагмент.""" matched = fftconvolve(segment, taps, mode="full") try: found = radio.find_radio_frame(matched, plan.marker, SAMPLES_PER_SYMBOL) except (RuntimeError, ValueError): return _failed_result(plan), None start = found["start_symbol_index"] symbols = found["symbol_samples"][start : start + len(plan.bits)] if len(symbols) < len(plan.bits): return _failed_result(plan, correlation=float(found["score"]), phase=int(found["sample_phase"])), None estimate = radio.estimate_carrier_parameters(symbols[: radio.MARKER_BIT_COUNT], plan.marker) corrected = radio.correct_phase_and_frequency( symbols, float(estimate["initial_phase_after_cfo"]), float(estimate["phase_increment"]), ) # Частоту берём готовой из оценщика, а не пересчитываем из приращения # фазы: два источника одной величины разошлись бы при правке любого из них. carrier_offset_hz = float(estimate["estimated_frequency_hz"]) carrier_coherence = float(estimate["cfo_coherence"]) error_vector = _error_vector_magnitude(corrected, plan.symbols) packet = radio.parse_radio_frame(radio.bpsk_demodulate(corrected)) header_parsed = packet is not None crc_valid = False payload: bytes | None = None if packet is not None: try: payload = parse_packet(packet).payload crc_valid = True except (CRCError, PacketError): payload = None result = FrameResult( index=plan.index, packet_bytes=len(plan.packet), frame_bits=len(plan.bits), correlation_score=float(found["score"]), sample_phase=int(found["sample_phase"]), carrier_offset_hz=carrier_offset_hz, carrier_coherence=carrier_coherence, error_vector_magnitude=error_vector, frame_found=True, header_parsed=header_parsed, crc_valid=crc_valid, fragment_recovered=payload is not None, ) return result, payload def _failed_result(plan: FramePlan, correlation: float = 0.0, phase: int = 0) -> FrameResult: return FrameResult( plan.index, len(plan.packet), len(plan.bits), correlation, phase, 0.0, 0.0, 0.0, False, False, False, False, ) def _error_vector_magnitude(received: np.ndarray, reference: np.ndarray) -> float: """Среднеквадратичное отклонение принятых символов от переданных. Нормируется на энергию переданных символов, поэтому не зависит от общего усиления тракта. """ if not len(received) or len(received) != len(reference): return 0.0 scale = np.vdot(reference, received) / np.vdot(reference, reference) difference = received - scale * reference reference_power = float(np.mean(np.abs(reference) ** 2)) if reference_power <= 0.0: return 0.0 return float(np.sqrt(np.mean(np.abs(difference) ** 2) / reference_power)) # ==================================================================== прогон def run_transfer(loopback_mode: str) -> tuple[TransferMetrics, tuple[FrameResult, ...], bytes | None, np.ndarray]: """Выполнить полный перенос изображения выбранным каналом.""" jpeg_bytes, applied_quality = prepare_jpeg() plans = build_frames(jpeg_bytes) taps = shaping_filter() waveforms = [shape_frame(plan.symbols, taps) for plan in plans] device = None if loopback_mode == "hardware": device = open_device() started = time.monotonic() results: list[FrameResult] = [] payloads: list[bytes] = [] last_segment = np.zeros(0, dtype=complex) try: for plan, waveform in zip(plans, waveforms): if device is None: segment = software_channel(waveform) else: segment = hardware_transfer(device, waveform) result, payload = receive_frame(segment, plan, taps) results.append(result) if payload is not None: payloads.append(payload) last_segment = segment finally: if device is not None: device.tx_destroy_buffer() transfer_seconds = time.monotonic() - started received_image: bytes | None = None if len(payloads) == len(plans): try: received_image = reassemble_image([decode_image_fragment(item) for item in payloads]) except Exception: received_image = None transmitted_samples = sum(len(item) for item in waveforms) air_time = transmitted_samples / SAMPLE_RATE_HZ recovered = sum(1 for item in results if item.fragment_recovered) successful = [item for item in results if item.frame_found] metrics = TransferMetrics( loopback_mode=loopback_mode, source_image=SOURCE_IMAGE.as_posix(), image_width=IMAGE_WIDTH, image_height=IMAGE_HEIGHT, jpeg_quality=applied_quality, jpeg_bytes=len(jpeg_bytes), fragment_data_size=FRAGMENT_DATA_SIZE, fragment_count=len(plans), packet_bytes_max=max(len(item.packet) for item in plans), packet_bytes_min=min(len(item.packet) for item in plans), frame_bits_max=max(len(item.bits) for item in plans), frame_bits_min=min(len(item.bits) for item in plans), symbol_rate=SYMBOL_RATE, samples_per_symbol=SAMPLES_PER_SYMBOL, sample_rate_hz=SAMPLE_RATE_HZ, carrier_hz=CARRIER_HZ, transmitted_samples=transmitted_samples, air_time_seconds=air_time, frames_received=len(successful), frames_failed_crc=sum(1 for item in results if item.frame_found and not item.crc_valid), fragments_recovered=recovered, fragment_delivery_fraction=recovered / len(plans) if plans else 0.0, image_reassembled=received_image is not None, image_identical=received_image == jpeg_bytes, received_image_bytes=len(received_image) if received_image else 0, transfer_seconds=transfer_seconds, # Канальная скорость считается по длительности сигнала в эфире и # характеризует тракт в обоих режимах. channel_bitrate_kbps=len(jpeg_bytes) * 8.0 / air_time / 1000.0 if air_time > 0 else 0.0, # Скорость по времени работы программы. В программном режиме она # отражает быстродействие вычислений, а не канал. wall_clock_bitrate_kbps=len(jpeg_bytes) * 8.0 / transfer_seconds / 1000.0 if transfer_seconds > 0 else 0.0, mean_correlation_score=float(np.mean([item.correlation_score for item in successful])) if successful else 0.0, mean_carrier_offset_hz=float(np.mean([item.carrier_offset_hz for item in successful])) if successful else 0.0, mean_error_vector_magnitude=float(np.mean([item.error_vector_magnitude for item in successful])) if successful else 0.0, tx_gain_db=TX_GAIN_DB if loopback_mode == "hardware" else 0.0, rx_gain_db=RX_GAIN_DB if loopback_mode == "hardware" else 0.0, attenuator_db=ATTENUATOR_DB if loopback_mode == "hardware" else 0.0, ) return metrics, tuple(results), received_image, last_segment # ==================================================================== проверки def run_functional_tests() -> tuple[FunctionalTestResult, ...]: """Проверки, не требующие оборудования.""" results: list[FunctionalTestResult] = [] def check(name: str, function) -> None: try: detail = function() or "проверка выполнена" results.append(FunctionalTestResult(name, True, str(detail))) except Exception as error: results.append(FunctionalTestResult(name, False, f"{type(error).__name__}: {error}")) def jpeg_fits_the_limit() -> str: jpeg, applied_quality = prepare_jpeg() assert 0 < len(jpeg) <= TARGET_JPEG_BYTES assert 5 <= applied_quality <= JPEG_QUALITY return f"JPEG занимает {len(jpeg)} байт при пределе {TARGET_JPEG_BYTES}, качество {applied_quality}" def fragments_roundtrip() -> str: jpeg, _ = prepare_jpeg() fragments = split_image_bytes(jpeg, image_id=IMAGE_ID, fragment_data_size=FRAGMENT_DATA_SIZE) restored = reassemble_image(fragments) assert restored == jpeg return f"{len(fragments)} фрагментов собираются в исходные байты" def frame_carries_the_packet() -> str: plans = build_frames(prepare_jpeg()[0]) for plan in plans: assert radio.parse_radio_frame(plan.bits) == plan.packet assert parse_packet(plan.packet).payload return f"все {len(plans)} кадров несут пакет с сошедшейся CRC32" def waveform_length_is_predictable() -> str: taps = shaping_filter() plan = build_frames(prepare_jpeg()[0])[0] waveform = shape_frame(plan.symbols, taps) guard_samples = GUARD_SYMBOL_COUNT * SAMPLES_PER_SYMBOL expected = 2 * guard_samples + len(plan.symbols) * SAMPLES_PER_SYMBOL + len(taps) - 1 assert len(waveform) == expected return f"кадр занимает {len(waveform)} отсчётов, что совпадает с расчётом" def scaling_stays_within_range() -> str: taps = shaping_filter() plan = build_frames(prepare_jpeg()[0])[0] samples = to_device_samples(shape_frame(plan.symbols, taps)) peak = float(np.max(np.abs(samples))) assert peak <= SAMPLE_SCALE return f"пиковая амплитуда {peak:.0f} не превышает предела {SAMPLE_SCALE}" def software_loopback_recovers_the_image() -> str: metrics, _, image, _ = run_transfer("software") assert metrics.image_identical assert metrics.fragment_delivery_fraction == 1.0 return f"программная петля вернула {len(image)} байт, идентичных исходным" def corrupted_frame_is_caught_by_crc() -> str: plan = build_frames(prepare_jpeg()[0])[0] damaged = plan.bits.copy() damaged[radio.PREAMBLE_BIT_COUNT + radio.RADIO_HEADER_BIT_COUNT + 5] ^= 1 packet = radio.parse_radio_frame(damaged) assert packet is not None try: parse_packet(packet) raise AssertionError("искажённый пакет принят") except (CRCError, PacketError): pass return "одиночная битовая ошибка в данных кадра обнаружена контрольной суммой" def lost_fragment_does_not_spoil_the_rest() -> str: jpeg, _ = prepare_jpeg() fragments = split_image_bytes(jpeg, image_id=IMAGE_ID, fragment_data_size=FRAGMENT_DATA_SIZE) assert len(fragments) > 1 try: reassemble_image(fragments[:-1]) raise AssertionError("сборка без фрагмента прошла успешно") except Exception: pass assert reassemble_image(fragments) == jpeg return "потеря фрагмента не портит остальные, сборка требует всех" def sample_rate_is_reachable() -> str: assert SAMPLE_RATE_HZ >= DEVICE_MINIMUM_SAMPLE_RATE_HZ assert SAMPLE_RATE_HZ == SYMBOL_RATE * SAMPLES_PER_SYMBOL return f"{SAMPLE_RATE_HZ} Гц не ниже предела устройства {DEVICE_MINIMUM_SAMPLE_RATE_HZ} Гц" def device_settings_are_within_limits() -> str: assert -89.0 <= TX_GAIN_DB <= 0.0 assert -3.0 <= RX_GAIN_DB <= 71.0 # опрошено у устройства assert RF_BANDWIDTH_HZ >= 200_000 assert 70_000_000 <= CARRIER_HZ <= 6_000_000_000 signal_bandwidth = SYMBOL_RATE * (1.0 + RRC_ROLLOFF) assert signal_bandwidth < RF_BANDWIDTH_HZ return ( f"усиления, полоса {RF_BANDWIDTH_HZ} Гц и несущая {CARRIER_HZ} Гц " f"внутри допустимых пределов, сигнал занимает {signal_bandwidth:.0f} Гц" ) checks = ( ("01. Размер подготовленного JPEG", jpeg_fits_the_limit), ("02. Разбиение и сборка изображения", fragments_roundtrip), ("03. Кадр несёт пакет с CRC32", frame_carries_the_packet), ("04. Длина сформированного кадра", waveform_length_is_predictable), ("05. Масштабирование в целые отсчёты", scaling_stays_within_range), ("06. Программная петля восстанавливает изображение", software_loopback_recovers_the_image), ("07. Обнаружение искажённого кадра", corrupted_frame_is_caught_by_crc), ("08. Потеря фрагмента", lost_fragment_does_not_spoil_the_rest), ("09. Достижимость частоты дискретизации", sample_rate_is_reachable), ("10. Настройки устройства в пределах", device_settings_are_within_limits), ) for name, function in checks: check(name, function) return tuple(results) # ==================================================================== вывод def _write_csv(path: Path, row_type: type, rows) -> None: with path.open("w", encoding="utf-8", newline="") as stream: writer = csv.DictWriter(stream, fieldnames=tuple(row_type.__dataclass_fields__)) writer.writeheader() writer.writerows(asdict(row) for row in rows) def save_plots(metrics: TransferMetrics, results: tuple[FrameResult, ...], segment: np.ndarray) -> None: """Построить графики принятого сигнала и качества приёма.""" taps = shaping_filter() matched = fftconvolve(segment, taps, mode="full") if segment.size else np.zeros(1, dtype=complex) figure, axis = plt.subplots(figsize=(6, 6)) sampled = matched[:: SAMPLES_PER_SYMBOL] axis.plot(sampled.real, sampled.imag, ".", markersize=2, alpha=0.5, color="#2878b5") axis.set_xlabel("Синфазная составляющая") axis.set_ylabel("Квадратурная составляющая") axis.set_title("Созвездие принятого сигнала") axis.grid(True, alpha=0.3) axis.axhline(0.0, color="black", linewidth=0.5) axis.axvline(0.0, color="black", linewidth=0.5) figure.tight_layout() figure.savefig(PLOT_PATHS[0], dpi=150) plt.close(figure) figure, axis = plt.subplots(figsize=(10, 5)) if segment.size: spectrum = np.fft.fftshift(np.abs(np.fft.fft(segment[: 1 << 16]))) frequencies = np.fft.fftshift(np.fft.fftfreq(len(spectrum), d=1.0 / SAMPLE_RATE_HZ)) / 1000.0 axis.plot(frequencies, 20.0 * np.log10(spectrum + 1e-12), linewidth=0.8, color="#2878b5") axis.set_xlabel("Отстройка от несущей, кГц") axis.set_ylabel("Уровень, дБ") axis.set_title("Спектр принятого сигнала") axis.grid(True, alpha=0.3) figure.tight_layout() figure.savefig(PLOT_PATHS[1], dpi=150) plt.close(figure) figure, axis = plt.subplots(figsize=(10, 5)) indexes = [item.index for item in results] axis.bar(indexes, [item.correlation_score for item in results], color="#2a9d55") axis.set_xlabel("Номер кадра") axis.set_ylabel("Нормированная корреляция") axis.set_title("Качество поиска кадра") axis.set_ylim(0.0, 1.05) axis.grid(True, axis="y", alpha=0.3) figure.tight_layout() figure.savefig(PLOT_PATHS[2], dpi=150) plt.close(figure) figure, axis = plt.subplots(figsize=(10, 5)) stages = ["Кадр найден", "Заголовок разобран", "CRC32 сошлась", "Фрагмент восстановлен"] values = [ sum(1 for item in results if item.frame_found), sum(1 for item in results if item.header_parsed), sum(1 for item in results if item.crc_valid), sum(1 for item in results if item.fragment_recovered), ] axis.bar(stages, values, color="#2878b5") axis.set_ylabel("Число кадров") axis.set_title(f"Доставка фрагментов, режим: {metrics.loopback_mode}") axis.set_ylim(0, max(values + [1]) * 1.2) axis.grid(True, axis="y", alpha=0.3) figure.tight_layout() figure.savefig(PLOT_PATHS[3], dpi=150) plt.close(figure) def write_report( metrics: TransferMetrics, results: tuple[FrameResult, ...], tests: tuple[FunctionalTestResult, ...], ) -> None: """Записать текстовый отчёт о прогоне.""" lines = [ "Lab042 — передача JPEG через кабельную петлю на одном PlutoSDR", "", "Режим прогона", f"- Канал: {metrics.loopback_mode}.", ] if metrics.loopback_mode == "hardware": lines.extend( [ f"- Схема: выход передатчика, аттенюатор {metrics.attenuator_db:.0f} дБ, вход приёмника.", f"- Несущая {metrics.carrier_hz / 1e6:.3f} МГц, одинаковая на передачу и приём.", f"- Усиление передатчика {metrics.tx_gain_db:.1f} дБ, приёмника {metrics.rx_gain_db:.1f} дБ, режим ручной.", "- Антенны не подключались: они создали бы путь распространения параллельно кабельному.", ] ) else: lines.append("- Оборудование не использовалось: канал замкнут в памяти. Прогон служит эталоном.") lines.extend( [ "", "Параметры тракта", f"- Символьная скорость {metrics.symbol_rate} симв/с, отсчётов на символ {metrics.samples_per_symbol}.", f"- Частота дискретизации {metrics.sample_rate_hz} Гц. Минимум устройства {DEVICE_MINIMUM_SAMPLE_RATE_HZ} Гц.", f"- Скругление {RRC_ROLLOFF}, длина фильтра {RRC_SPAN_SYMBOLS} символов, защитный интервал {GUARD_SYMBOL_COUNT} символов.", "- Значения перенесены из Lab018 без изменений, кроме передискретизации: она поднята с 32 до 128,", " потому что частота 640 000 Гц из Lab018 ниже предела устройства втрое.", "", "Передаваемое изображение", f"- Источник {metrics.source_image}, {metrics.image_width}x{metrics.image_height}, качество {metrics.jpeg_quality}.", f"- Размер JPEG {metrics.jpeg_bytes} байт.", f"- Фрагментов {metrics.fragment_count} по {metrics.fragment_data_size} байт данных.", f"- Пакет протокола от {metrics.packet_bytes_min} до {metrics.packet_bytes_max} байт, " f"радиокадр от {metrics.frame_bits_min} до {metrics.frame_bits_max} бит.", "- Последний фрагмент изображения короче остальных, поэтому его кадр меньше. Размер каждого кадра приведён в lab042_frames.csv.", f"- Всего передано {metrics.transmitted_samples} отсчётов, что соответствует {metrics.air_time_seconds:.3f} с эфира.", "", "Результат приёма", f"- Кадров найдено {metrics.frames_received} из {metrics.fragment_count}.", f"- Кадров с несошедшейся CRC32: {metrics.frames_failed_crc}.", f"- Фрагментов восстановлено {metrics.fragments_recovered}, доля {metrics.fragment_delivery_fraction * 100.0:.3f}%.", f"- Изображение собрано: {'да' if metrics.image_reassembled else 'нет'}.", f"- Совпадает с переданным побайтово: {'да' if metrics.image_identical else 'нет'}.", f"- Размер принятого изображения {metrics.received_image_bytes} байт.", "", "Качество сигнала", f"- Средняя нормированная корреляция при поиске кадра {metrics.mean_correlation_score:.5f}.", f"- Средняя оценка ухода несущей {metrics.mean_carrier_offset_hz:.3f} Гц.", f"- Средняя величина вектора ошибки {metrics.mean_error_vector_magnitude * 100.0:.3f}%.", "", "Скорость", f"- Длительность сигнала в эфире {metrics.air_time_seconds:.3f} с.", f"- Канальная скорость {metrics.channel_bitrate_kbps:.3f} кбит/с — полезные байты, делённые на время эфира.", f"- Полное время работы программы {metrics.transfer_seconds:.3f} с, что даёт {metrics.wall_clock_bitrate_kbps:.3f} кбит/с.", "- В программном режиме вторая величина отражает быстродействие вычислений, а не пропускную способность канала.", "", "Кадры", "номер | корреляция | фаза | уход несущей, Гц | достоверность оценки | вектор ошибки, % | найден | заголовок | CRC32 | фрагмент", ] ) for item in results: lines.append( f"{item.index} | {item.correlation_score:.5f} | {item.sample_phase} | " f"{item.carrier_offset_hz:.3f} | {item.carrier_coherence:.5f} | {item.error_vector_magnitude * 100.0:.3f} | " f"{'да' if item.frame_found else 'нет'} | {'да' if item.header_parsed else 'нет'} | " f"{'да' if item.crc_valid else 'нет'} | {'да' if item.fragment_recovered else 'нет'}" ) lines.extend(["", "Функциональные проверки"]) lines.extend(f"- {'ПРОЙДЕНО' if item.passed else 'ОШИБКА'} — {item.name}: {item.detail}" for item in tests) lines.extend( [ "", "Ограничения", "- Передатчик и приёмник принадлежат одному устройству и работают от общего опорного", " генератора, поэтому уход несущей между ними мал и не характеризует реальную линию.", "- Повторная передача потерянных фрагментов не выполняется: потери фиксируются, но не восстанавливаются.", "- Исправление стираний не подключалось.", "- Разведение на два устройства и выход в эфир — предмет Lab043 и Lab044.", "", "Созданные файлы", ] ) lines.extend( f"- {path.as_posix()}" for path in (RECEIVED_IMAGE_PATH, SUMMARY_CSV, FRAMES_CSV, REPORT_PATH, *PLOT_PATHS) ) REPORT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") def validate_outputs(metrics: TransferMetrics, results: tuple[FrameResult, ...], tests: tuple[FunctionalTestResult, ...]) -> None: """Проверить состав и связность полученных файлов.""" assert len(tests) == 10 and all(item.passed for item in tests) assert len(results) == metrics.fragment_count for path in (SUMMARY_CSV, FRAMES_CSV): with path.open(encoding="utf-8", newline="") as stream: rows = list(csv.DictReader(stream)) assert rows assert len(list(csv.DictReader(FRAMES_CSV.open(encoding="utf-8", newline="")))) == metrics.fragment_count REPORT_PATH.read_text(encoding="utf-8") for path in PLOT_PATHS: assert path.exists() and path.stat().st_size > 1000 expected = {path.resolve() for path in (SUMMARY_CSV, FRAMES_CSV, REPORT_PATH, *PLOT_PATHS)} if metrics.image_reassembled: expected.add(RECEIVED_IMAGE_PATH.resolve()) actual = {path.resolve() for path in OUTPUT_DIRECTORY.rglob("*") if path.is_file()} assert actual == expected, f"лишние или недостающие файлы: {actual ^ expected}" forbidden = {".pyc", ".npy", ".npz", ".pickle", ".pkl", ".log"} assert not any(path.suffix.lower() in forbidden for path in actual) def main() -> None: parser = argparse.ArgumentParser(description="Lab042: передача JPEG через кабельную петлю") parser.add_argument( "--loopback", choices=("software", "hardware"), default="software", help="software замыкает канал в памяти, hardware использует PlutoSDR", ) arguments = parser.parse_args() OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) tests = run_functional_tests() metrics, results, received_image, segment = run_transfer(arguments.loopback) if received_image is not None: RECEIVED_IMAGE_PATH.write_bytes(received_image) _write_csv(SUMMARY_CSV, TransferMetrics, (metrics,)) _write_csv(FRAMES_CSV, FrameResult, results) save_plots(metrics, results, segment) write_report(metrics, results, tests) validate_outputs(metrics, results, tests) print( f"Lab042 завершена, режим {metrics.loopback_mode}: " f"фрагментов {metrics.fragments_recovered}/{metrics.fragment_count}, " f"изображение {'идентично' if metrics.image_identical else 'НЕ совпадает'}, " f"{len(tests)}/{len(tests)} проверок, два CSV и четыре PNG" ) if __name__ == "__main__": main()