Lab042: transmit an image over the air, add the air loopback mode
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.
This commit is contained in:
@@ -4,20 +4,31 @@
|
||||
весь тракт был собран и проверен на моделях, но ни одного вызова передачи
|
||||
через SDR в проекте не было.
|
||||
|
||||
Схема опыта:
|
||||
|
||||
выход передатчика → аттенюатор 30 дБ → вход приёмника
|
||||
|
||||
Передатчик и приёмник принадлежат одному устройству и работают от общего
|
||||
опорного генератора, поэтому ухода несущей между ними практически нет.
|
||||
Разведение на два устройства — предмет Lab043.
|
||||
|
||||
Лабораторная работает в двух режимах. Программный замыкает канал в памяти
|
||||
и оборудования не требует; он служит эталоном, с которым сравнивается
|
||||
аппаратный прогон. Разность между режимами и есть вклад радиотракта.
|
||||
Три режима трассы:
|
||||
|
||||
software канал замкнут в памяти, оборудование не нужно. Служит
|
||||
эталоном: всё, чем отличается аппаратный прогон, внесено
|
||||
радиотрактом.
|
||||
|
||||
cable выход передатчика → аттенюатор 30 дБ → вход приёмника.
|
||||
Основной опыт лабораторной: уровни известны, посторонних
|
||||
путей распространения нет.
|
||||
|
||||
air две антенны по воздуху на малом расстоянии, аттенюатора нет.
|
||||
Вспомогательный режим для проверки тракта устройства, когда
|
||||
кабельная сборка недоступна. Уровни контролируются хуже,
|
||||
возможны переотражения, поэтому измерением он не считается.
|
||||
|
||||
python -m experiments.lab042_pluto_image_loopback --loopback software
|
||||
python -m experiments.lab042_pluto_image_loopback --loopback hardware
|
||||
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
|
||||
"""
|
||||
@@ -28,6 +39,7 @@ import argparse
|
||||
import csv
|
||||
import functools
|
||||
import io
|
||||
import math
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -74,13 +86,41 @@ DEVICE_MINIMUM_SAMPLE_RATE_HZ = 2_083_333
|
||||
|
||||
# ---------------------------------------------------------------- параметры устройства
|
||||
PLUTO_URI = "ip:192.168.2.1"
|
||||
CARRIER_HZ = 435_000_000
|
||||
# Кабельная петля: несущая произвольна, сигнал в эфир не выходит.
|
||||
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
|
||||
RX_BUFFER_SIZE = 262_144
|
||||
# Приёмный буфер подбирается под длину кадра: отдельные чтения не дают
|
||||
# непрерывного потока отсчётов, и кадр, растянутый на несколько буферов,
|
||||
# рвётся на стыках. Измерено: кадр 566 528 отсчётов при буфере 262 144
|
||||
# находился по преамбуле, но данные за ней приходили испорченными.
|
||||
RX_BUFFER_LIMIT = 1 << 22
|
||||
# Ослабление внешнего аттенюатора AT30S, включённого в разрыв тракта.
|
||||
ATTENUATOR_DB = 30.0
|
||||
# PlutoSDR принимает целые отсчёты. Множитель приводит сигнал с амплитудой
|
||||
@@ -135,6 +175,7 @@ class FrameResult:
|
||||
sample_phase: int
|
||||
carrier_offset_hz: float
|
||||
carrier_coherence: float
|
||||
cfo_correction_applied: bool
|
||||
error_vector_magnitude: float
|
||||
frame_found: bool
|
||||
header_parsed: bool
|
||||
@@ -147,6 +188,8 @@ class TransferMetrics:
|
||||
"""Сводка одного прогона."""
|
||||
|
||||
loopback_mode: str
|
||||
path_description: str
|
||||
antenna_distance_cm: float
|
||||
source_image: str
|
||||
image_width: int
|
||||
image_height: int
|
||||
@@ -299,7 +342,46 @@ def software_channel(waveform: np.ndarray) -> np.ndarray:
|
||||
return waveform.copy()
|
||||
|
||||
|
||||
def open_device():
|
||||
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 и настроить его для кабельной петли.
|
||||
|
||||
Настройки выбраны так, чтобы приёмник не мог быть повреждён: с
|
||||
@@ -311,16 +393,16 @@ def open_device():
|
||||
|
||||
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_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.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.rx_hardwaregain_chan0 = float(rx_gain_db)
|
||||
device.rx_buffer_size = int(rx_buffer_size)
|
||||
# Циклический буфер повторял бы кадр бесконечно: передача не
|
||||
# заканчивалась бы, а приёмник видел тысячи копий одного кадра.
|
||||
device.tx_cyclic_buffer = False
|
||||
@@ -328,9 +410,13 @@ def open_device():
|
||||
|
||||
|
||||
def hardware_transfer(device, frame_waveform: np.ndarray) -> np.ndarray:
|
||||
"""Передать один кадр и записать отрезок эфира, содержащий его."""
|
||||
"""Передать один кадр и записать отрезок эфира, содержащий его.
|
||||
|
||||
needed_samples = int(len(frame_waveform) * RECEIVE_MARGIN)
|
||||
Читается ровно один буфер. Склеивать несколько нельзя: между
|
||||
отдельными чтениями поток отсчётов не является непрерывным, и кадр,
|
||||
попавший на стык, разрушается. Поэтому буфер заранее выбран так, чтобы
|
||||
вместить кадр целиком с запасом.
|
||||
"""
|
||||
|
||||
# Приём запускается до передачи. Первый буфер отбрасывается: он собран
|
||||
# до того, как кадр ушёл в эфир, и его назначение — привести приёмный
|
||||
@@ -340,15 +426,10 @@ def hardware_transfer(device, frame_waveform: np.ndarray) -> np.ndarray:
|
||||
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)
|
||||
segment = np.asarray(device.rx(), dtype=np.complex64)
|
||||
|
||||
device.tx_destroy_buffer()
|
||||
return np.concatenate(chunks) / SAMPLE_SCALE
|
||||
return segment / SAMPLE_SCALE
|
||||
|
||||
|
||||
# ==================================================================== приём
|
||||
@@ -371,16 +452,32 @@ def receive_frame(segment: np.ndarray, plan: FramePlan, taps: np.ndarray) -> tup
|
||||
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"])
|
||||
|
||||
# Решение о частотной коррекции принимается защитой из 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))
|
||||
@@ -403,6 +500,7 @@ def receive_frame(segment: np.ndarray, plan: FramePlan, taps: np.ndarray) -> tup
|
||||
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,
|
||||
@@ -412,10 +510,17 @@ def receive_frame(segment: np.ndarray, plan: FramePlan, taps: np.ndarray) -> tup
|
||||
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, 0.0, False, False, False, False,
|
||||
correlation, phase, 0.0, 0.0, False, float("nan"), False, False, False, False,
|
||||
)
|
||||
|
||||
|
||||
@@ -426,22 +531,51 @@ def _error_vector_magnitude(received: np.ndarray, reference: np.ndarray) -> floa
|
||||
общего усиления тракта.
|
||||
"""
|
||||
|
||||
# Ноль здесь возвращать нельзя: он означал бы идеальный приём там, где
|
||||
# величину вычислить не удалось. Такое значение уже один раз скрыло
|
||||
# отказ приёмника, показав 0,000% при непрошедшей контрольной сумме.
|
||||
if not len(received) or len(received) != len(reference):
|
||||
return 0.0
|
||||
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 0.0
|
||||
return float("nan")
|
||||
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]:
|
||||
"""Выполнить полный перенос изображения выбранным каналом."""
|
||||
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()
|
||||
|
||||
@@ -450,8 +584,16 @@ def run_transfer(loopback_mode: str) -> tuple[TransferMetrics, tuple[FrameResult
|
||||
waveforms = [shape_frame(plan.symbols, taps) for plan in plans]
|
||||
|
||||
device = None
|
||||
if loopback_mode == "hardware":
|
||||
device = open_device()
|
||||
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] = []
|
||||
@@ -488,8 +630,16 @@ def run_transfer(loopback_mode: str) -> tuple[TransferMetrics, tuple[FrameResult
|
||||
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,
|
||||
@@ -504,7 +654,7 @@ def run_transfer(loopback_mode: str) -> tuple[TransferMetrics, tuple[FrameResult
|
||||
symbol_rate=SYMBOL_RATE,
|
||||
samples_per_symbol=SAMPLES_PER_SYMBOL,
|
||||
sample_rate_hz=SAMPLE_RATE_HZ,
|
||||
carrier_hz=CARRIER_HZ,
|
||||
carrier_hz=carrier_hz,
|
||||
transmitted_samples=transmitted_samples,
|
||||
air_time_seconds=air_time,
|
||||
frames_received=len(successful),
|
||||
@@ -523,10 +673,10 @@ def run_transfer(loopback_mode: str) -> tuple[TransferMetrics, tuple[FrameResult
|
||||
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,
|
||||
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
|
||||
@@ -632,6 +782,18 @@ def run_functional_tests() -> tuple[FunctionalTestResult, ...]:
|
||||
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),
|
||||
@@ -643,6 +805,7 @@ def run_functional_tests() -> tuple[FunctionalTestResult, ...]:
|
||||
("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:
|
||||
@@ -737,15 +900,26 @@ def write_report(
|
||||
f"- Канал: {metrics.loopback_mode}.",
|
||||
]
|
||||
|
||||
if metrics.loopback_mode == "hardware":
|
||||
lines.append(f"- Трасса: {metrics.path_description}.")
|
||||
|
||||
if metrics.loopback_mode == "cable":
|
||||
lines.extend(
|
||||
[
|
||||
f"- Схема: выход передатчика, аттенюатор {metrics.attenuator_db:.0f} дБ, вход приёмника.",
|
||||
f"- Несущая {metrics.carrier_hz / 1e6:.3f} МГц, одинаковая на передачу и приём.",
|
||||
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("- Оборудование не использовалось: канал замкнут в памяти. Прогон служит эталоном.")
|
||||
|
||||
@@ -827,7 +1001,7 @@ def write_report(
|
||||
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(tests) == 11 and all(item.passed for item in tests)
|
||||
assert len(results) == metrics.fragment_count
|
||||
|
||||
for path in (SUMMARY_CSV, FRAMES_CSV):
|
||||
@@ -855,16 +1029,45 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Lab042: передача JPEG через кабельную петлю")
|
||||
parser.add_argument(
|
||||
"--loopback",
|
||||
choices=("software", "hardware"),
|
||||
choices=("software", "cable", "air"),
|
||||
default="software",
|
||||
help="software замыкает канал в памяти, hardware использует PlutoSDR",
|
||||
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()
|
||||
metrics, results, received_image, segment = run_transfer(arguments.loopback)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user