First transmission through a real radio path in the project. A 4756-byte JPEG went from transmitter to receiver over two antennas 60 cm apart at 225 MHz and reassembled byte-identical, reproduced across three runs. The cable loopback still needs an SMA male-to-male adapter that has not arrived, so this air run is a track verification, not the controlled measurement: room levels are not held the way a cable holds them. Its purpose was to exercise the device wrapper the models cannot cover. Four defects the software model passed but the hardware exposed, each reproduced and fixed: - a 566 528-sample frame did not fit the 262 144 receive buffer, and the sample stream is not continuous across separate reads, so a frame on the seam was destroyed. Reception now uses one buffer sized to the frame - carrier correction was always applied, but with a shared reference the true offset is near zero and the 80-symbol marker estimate is noise; correcting by it rotated phase along the whole frame, 65% bit errors measured. Ported should_apply_cfo_correction from Lab023 - receiver gain of 20 dB left the signal at 0.0003 of full scale. Levels set by measurement: RX 50 dB, TX -10 dB give zero bit errors with margin - the error-vector metric returned zero on a failed receive, reporting a perfect signal where there was none, the same class of defect as the written-in figures of Lab041. Replaced with an explicit not-computed value The air TX gain ceiling is now derived from the link budget by antenna distance rather than a constant. Three loopback modes: software, cable, air. PROJECT_LOG entry 019 records the run and the defects. Software and cable results stay reproducible; 125 tests pass, gate passes.
1091 lines
54 KiB
Python
1091 lines
54 KiB
Python
"""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()
|