Lab042: transmit a JPEG through a cable loopback
First lab in the project that switches the transmitter on. Lab001-Lab041 built the whole chain but never called into an SDR to send anything. Two modes. Software closes the channel in memory and needs no hardware; it is the reference the hardware run is measured against, so the difference between them is what the radio path contributes. Hardware drives one PlutoSDR with its transmit output looped back through a 30 dB attenuator into its own receive input. Everything outside the device wrapper is reused from protocol/ unchanged: image_fragments, packet and the primitives in bpsk_radio. Software run: a 4756-byte JPEG in 10 fragments, 5 272 064 samples, 2.059 s of signal, 10 of 10 fragments recovered, image byte-identical, repeatable across runs. Channel rate 18.475 kbit/s, below the 20 kbit/s symbol rate because of framing and guards. Three defects found by the verifier and fixed: - the guard interval was only appended after the frame. Lab018 puts one on both sides, and the leading one is the one that matters: the first samples of a transmission are unreliable, so the preamble would have landed exactly on the transient. Invisible in software mode, which has no converter and no transient - transmission was started before reception, contradicting section 8 of the specification and its own trap 3. The receiver now runs first and the first buffer is discarded, as in Lab024a - packet and frame size were reported from the first frame only, while the tenth is 172 bytes against 536. Sizes are now per frame in the CSV and the report gives the honest range Also fixed before verification: the JPEG did not fit its own 5000-byte limit, so quality is now lowered until it does and reported as a result; and the wall-clock rate was presented as a channel rate, which in software mode measures computation, not a channel. Artifacts are left out deliberately. data/processed/lab042 currently holds the software reference; the committed results should be the real experiment, which needs the cable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
881
experiments/lab042_pluto_image_loopback.py
Normal file
881
experiments/lab042_pluto_image_loopback.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user