diff --git a/experiments/lab042_pluto_image_loopback.py b/experiments/lab042_pluto_image_loopback.py new file mode 100644 index 0000000..992c125 --- /dev/null +++ b/experiments/lab042_pluto_image_loopback.py @@ -0,0 +1,881 @@ +"""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()