Files
SDR-Rover/experiments/lab042_pluto_image_loopback.py
LittleSam129 a37ad1f7b3 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>
2026-08-11 13:28:47 +03:00

882 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Lab042: передача JPEG через кабельную петлю на одном PlutoSDR.
Первая лабораторная, в которой включается передатчик. За Lab001Lab041
весь тракт был собран и проверен на моделях, но ни одного вызова передачи
через 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()