"""Lab042: передача JPEG через кабельную петлю на одном PlutoSDR. Первая лабораторная, в которой включается передатчик. За Lab001–Lab041 весь тракт был собран и проверен на моделях, но ни одного вызова передачи через SDR в проекте не было. Передатчик и приёмник принадлежат одному устройству и работают от общего опорного генератора, поэтому ухода несущей между ними практически нет. Разведение на два устройства — предмет Lab043. Три режима трассы: software канал замкнут в памяти, оборудование не нужно. Служит эталоном: всё, чем отличается аппаратный прогон, внесено радиотрактом. cable выход передатчика → аттенюатор 30 дБ → вход приёмника. Основной опыт лабораторной: уровни известны, посторонних путей распространения нет. air две антенны по воздуху на малом расстоянии, аттенюатора нет. Вспомогательный режим для проверки тракта устройства, когда кабельная сборка недоступна. Уровни контролируются хуже, возможны переотражения, поэтому измерением он не считается. python -m experiments.lab042_pluto_image_loopback --loopback software python -m experiments.lab042_pluto_image_loopback --loopback cable python -m experiments.lab042_pluto_image_loopback --loopback air --antenna-distance-cm 30 Несущая по умолчанию: 435 МГц для кабеля, где она произвольна, и 225 МГц для эфира — внутри целевого диапазона 200-250 МГц из Записи 018 журнала. Постановка задачи: docs/lab042_hardware_loopback_spec.md """ from __future__ import annotations import argparse import csv import functools import io import math 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" # Кабельная петля: несущая произвольна, сигнал в эфир не выходит. CABLE_CARRIER_HZ = 435_000_000 # Эфирная петля: несущая взята внутри целевого диапазона проекта # 200-250 МГц, закреплённого Записью 018 журнала. Это заодно первый # практический опыт работы в выбранной полосе. AIR_CARRIER_HZ = 225_000_000 CARRIER_HZ = CABLE_CARRIER_HZ # Данные для расчёта безопасного уровня в эфирной петле. PLUTO_MAXIMUM_OUTPUT_DBM = 7.0 RECEIVER_DAMAGE_THRESHOLD_DBM = 2.5 RECEIVER_SAFETY_MARGIN_DB = 15.0 # Усиление антенн принято нулевым: штыри на 108 МГц работают вне расчётной # частоты, и завышать их вклад в сторону безопасности нельзя. ASSUMED_ANTENNA_GAIN_DBI = 0.0 # Рабочие уровни для эфира подобраны измерением 10 августа 2026 года: # трасса 60 см, несущая 225 МГц. При усилении приёмника 20 дБ доля # ошибочных битов составляла 2,58%, при 35 дБ — 0,84%, при 50 дБ — ноль. # По передатчику: при -20 и -15 дБ отдельные кадры всё же не проходили # контрольную сумму, при -10 дБ ошибок не было ни при одном из проверенных # усилений приёмника (45, 50 и 55 дБ). Взят -10 дБ как единственный уровень # с запасом. Расчётный предел для трассы 60 см составляет -4,5 дБ. AIR_TX_GAIN_DB = -10.0 AIR_RX_GAIN_DB = 50.0 DEFAULT_ANTENNA_DISTANCE_CM = 60.0 RF_BANDWIDTH_HZ = 200_000 TX_GAIN_DB = -30.0 # Диапазон усиления приёмника получен опросом устройства 10 августа 2026 # года: hardwaregain_available = [-3 1 71] дБ в ручном режиме. RX_GAIN_DB = 20.0 # Приёмный буфер подбирается под длину кадра: отдельные чтения не дают # непрерывного потока отсчётов, и кадр, растянутый на несколько буферов, # рвётся на стыках. Измерено: кадр 566 528 отсчётов при буфере 262 144 # находился по преамбуле, но данные за ней приходили испорченными. RX_BUFFER_LIMIT = 1 << 22 # Ослабление внешнего аттенюатора 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 cfo_correction_applied: bool error_vector_magnitude: float frame_found: bool header_parsed: bool crc_valid: bool fragment_recovered: bool @dataclass(frozen=True) class TransferMetrics: """Сводка одного прогона.""" loopback_mode: str path_description: str antenna_distance_cm: float 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 # ==================================================================== подготовка @functools.lru_cache(maxsize=4) 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 maximum_air_tx_gain_db(antenna_distance_cm: float, carrier_hz: int) -> float: """Рассчитать предельное усиление передатчика для эфирной петли. Аттенюатора в цепи нет, поэтому приёмник защищён только ослаблением на трассе. Предел выводится из бюджета линии, а не задан константой: на малом расстоянии он строже, на большом мягче. """ if antenna_distance_cm <= 0.0: raise ValueError("Расстояние между антеннами должно быть положительным") distance_km = antenna_distance_cm / 100.0 / 1000.0 path_loss_db = 32.44 + 20.0 * math.log10(distance_km) + 20.0 * math.log10(carrier_hz / 1e6) # В ближней зоне формула даёт отрицательные потери; принимаем ноль, # то есть считаем, что вся мощность попадает в приёмник. path_loss_db = max(path_loss_db, 0.0) allowed_input_dbm = RECEIVER_DAMAGE_THRESHOLD_DBM - RECEIVER_SAFETY_MARGIN_DB return allowed_input_dbm + path_loss_db - PLUTO_MAXIMUM_OUTPUT_DBM - 2.0 * ASSUMED_ANTENNA_GAIN_DBI def _receive_buffer_size(frame_samples: int) -> int: """Подобрать размер приёмного буфера, вмещающий кадр целиком.""" needed = int(frame_samples * RECEIVE_MARGIN) size = 1 << (needed - 1).bit_length() if size > RX_BUFFER_LIMIT: raise ValueError( f"Кадру нужен буфер {size} отсчётов, что выше предела {RX_BUFFER_LIMIT}. " f"Уменьшите размер фрагмента изображения" ) return size def open_device( carrier_hz: int = CABLE_CARRIER_HZ, tx_gain_db: float = TX_GAIN_DB, rx_buffer_size: int = 1 << 21, rx_gain_db: float = RX_GAIN_DB, ): """Открыть 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: """Передать один кадр и записать отрезок эфира, содержащий его. Читается ровно один буфер. Склеивать несколько нельзя: между отдельными чтениями поток отсчётов не является непрерывным, и кадр, попавший на стык, разрушается. Поэтому буфер заранее выбран так, чтобы вместить кадр целиком с запасом. """ # Приём запускается до передачи. Первый буфер отбрасывается: он собран # до того, как кадр ушёл в эфир, и его назначение — привести приёмный # тракт в рабочее состояние. Тот же приём использован в Lab024a. device.rx() device.tx_destroy_buffer() device.tx(to_device_samples(frame_waveform)) segment = np.asarray(device.rx(), dtype=np.complex64) device.tx_destroy_buffer() return segment / 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) # Частоту берём готовой из оценщика, а не пересчитываем из приращения # фазы: два источника одной величины разошлись бы при правке любого из них. carrier_offset_hz = float(estimate["estimated_frequency_hz"]) carrier_coherence = float(estimate["cfo_coherence"]) # Решение о частотной коррекции принимается защитой из Lab023. При # общем опорном генераторе истинный уход близок к нулю, а оценка по # маркеру длиной 80 символов состоит почти целиком из шума. Коррекция # таким значением поворачивает фазу вдоль всего кадра и портит его # сильнее, чем отсутствие коррекции: измерено 65% ошибочных битов. cfo_applied = radio.should_apply_cfo_correction( carrier_offset_hz, float(estimate["phase_consistency"]), float(estimate["coherence_gain"]), ) if cfo_applied: corrected = radio.correct_phase_and_frequency( symbols, float(estimate["initial_phase_after_cfo"]), float(estimate["phase_increment"]), ) else: corrected = radio.correct_phase_and_frequency(symbols, float(estimate["constant_phase"]), 0.0) 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, cfo_correction_applied=cfo_applied, 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 _mean_finite(values: list[float]) -> float: """Среднее по величинам, которые удалось вычислить.""" finite = [value for value in values if np.isfinite(value)] return float(np.mean(finite)) if finite else float("nan") 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, False, float("nan"), False, False, False, False, ) def _error_vector_magnitude(received: np.ndarray, reference: np.ndarray) -> float: """Среднеквадратичное отклонение принятых символов от переданных. Нормируется на энергию переданных символов, поэтому не зависит от общего усиления тракта. """ # Ноль здесь возвращать нельзя: он означал бы идеальный приём там, где # величину вычислить не удалось. Такое значение уже один раз скрыло # отказ приёмника, показав 0,000% при непрошедшей контрольной сумме. if not len(received) or len(received) != len(reference): return float("nan") 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 float("nan") return float(np.sqrt(np.mean(np.abs(difference) ** 2) / reference_power)) # ==================================================================== прогон def run_transfer( loopback_mode: str, carrier_hz: int | None = None, antenna_distance_cm: float = DEFAULT_ANTENNA_DISTANCE_CM, tx_gain_db: float | None = None, ) -> tuple[TransferMetrics, tuple[FrameResult, ...], bytes | None, np.ndarray]: """Выполнить полный перенос изображения выбранным каналом. Режимы: software замыкает канал в памяти, cable проходит через аттенюатор по кабелю, air — через пару антенн по воздуху. """ if tx_gain_db is None: tx_gain_db = AIR_TX_GAIN_DB if loopback_mode == "air" else TX_GAIN_DB rx_gain_db = AIR_RX_GAIN_DB if loopback_mode == "air" else RX_GAIN_DB if loopback_mode == "air": carrier_hz = carrier_hz if carrier_hz is not None else AIR_CARRIER_HZ ceiling = maximum_air_tx_gain_db(antenna_distance_cm, carrier_hz) if tx_gain_db > ceiling: raise ValueError( f"При расстоянии {antenna_distance_cm:.0f} см усиление передатчика ограничено " f"{ceiling:.1f} дБ, запрошено {tx_gain_db:.1f} дБ. Аттенюатора в цепи нет, " f"и приёмник защищён только ослаблением на трассе" ) else: carrier_hz = carrier_hz if carrier_hz is not None else CABLE_CARRIER_HZ 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 in ("cable", "air"): buffer_size = _receive_buffer_size(max(len(item) for item in waveforms)) device = open_device(carrier_hz, tx_gain_db, buffer_size, rx_gain_db) if device is not None: # Прогрев тракта перед началом опыта, как и первый отбрасываемый # приёмный буфер. Проверка показала, что отказы кадров случайны и # к первой передаче не привязаны, поэтому прогрев остаётся мерой # предосторожности, а не исправлением известного дефекта. hardware_transfer(device, waveforms[0]) 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] path_description = { "software": "канал замкнут в памяти, оборудование не использовалось", "cable": f"кабель через аттенюатор {ATTENUATOR_DB:.0f} дБ", "air": f"две антенны по воздуху, расстояние {antenna_distance_cm:.0f} см, аттенюатора нет", }[loopback_mode] metrics = TransferMetrics( loopback_mode=loopback_mode, path_description=path_description, antenna_distance_cm=antenna_distance_cm if loopback_mode == "air" else 0.0, 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=_mean_finite([item.error_vector_magnitude for item in successful]), tx_gain_db=tx_gain_db if loopback_mode != "software" else 0.0, rx_gain_db=rx_gain_db if loopback_mode != "software" else 0.0, attenuator_db=ATTENUATOR_DB if loopback_mode == "cable" 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} Гц" ) def air_gain_ceiling_is_enforced() -> str: try: ceiling = maximum_air_tx_gain_db(DEFAULT_ANTENNA_DISTANCE_CM, AIR_CARRIER_HZ) run_transfer("air", tx_gain_db=ceiling + 1.0) raise AssertionError("превышение потолка усиления в эфирном режиме принято") except ValueError: pass near = maximum_air_tx_gain_db(5.0, AIR_CARRIER_HZ) far = maximum_air_tx_gain_db(200.0, AIR_CARRIER_HZ) assert near < far, "на малом расстоянии предел обязан быть строже" return f"предел зависит от расстояния: {near:.1f} дБ на 5 см против {far:.1f} дБ на 200 см" 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), ("11. Потолок усиления в эфирном режиме", air_gain_ceiling_is_enforced), ) 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}.", ] lines.append(f"- Трасса: {metrics.path_description}.") if metrics.loopback_mode == "cable": lines.extend( [ f"- Несущая {metrics.carrier_hz / 1e6:.3f} МГц, одинаковая на передачу и приём. Сигнал в эфир не выходит, поэтому её значение произвольно.", f"- Усиление передатчика {metrics.tx_gain_db:.1f} дБ, приёмника {metrics.rx_gain_db:.1f} дБ, режим ручной.", "- Антенны не подключались: они создали бы путь распространения параллельно кабельному.", ] ) elif metrics.loopback_mode == "air": lines.extend( [ f"- Несущая {metrics.carrier_hz / 1e6:.3f} МГц — внутри целевого диапазона 200-250 МГц, закреплённого Записью 018.", f"- Усиление передатчика {metrics.tx_gain_db:.1f} дБ, приёмника {metrics.rx_gain_db:.1f} дБ, режим ручной.", f"- Антенны разнесены на {metrics.antenna_distance_cm:.0f} см. Аттенюатора в цепи нет: приёмник защищён ослаблением на трассе.", "- Использованы штыри, рассчитанные на 108 МГц. На рабочей частоте они работают как полуволновые, согласование плохое, но для этой дистанции достаточное.", "- Уровни в эфирной трассе не контролируются так же строго, как в кабельной: возможны переотражения от предметов в помещении. Прогон служит проверкой тракта устройства, а не измерением.", ] ) 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) == 11 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", "cable", "air"), default="software", help="software замыкает канал в памяти, cable — кабель через аттенюатор, air — две антенны по воздуху", ) parser.add_argument( "--carrier-mhz", type=float, default=None, help="несущая, МГц. По умолчанию 435 для кабеля и 225 для эфира", ) parser.add_argument( "--antenna-distance-cm", type=float, default=DEFAULT_ANTENNA_DISTANCE_CM, help="расстояние между антеннами в эфирном режиме, см", ) parser.add_argument( "--tx-gain-db", type=float, default=None, help=f"усиление передатчика, дБ. По умолчанию {TX_GAIN_DB:.0f} для кабеля и {AIR_TX_GAIN_DB:.0f} для эфира. В эфире предел считается по расстоянию", ) arguments = parser.parse_args() OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) # Файлы прошлого прогона удаляются: иначе принятое изображение от # удачного запуска осталось бы лежать рядом с результатами неудачного. for stale in OUTPUT_DIRECTORY.glob("*"): if stale.is_file(): stale.unlink() tests = run_functional_tests() carrier_hz = int(arguments.carrier_mhz * 1e6) if arguments.carrier_mhz else None metrics, results, received_image, segment = run_transfer( arguments.loopback, carrier_hz=carrier_hz, antenna_distance_cm=arguments.antenna_distance_cm, tx_gain_db=arguments.tx_gain_db, ) 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()