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:
LittleSam129
2026-08-17 11:50:04 +03:00
parent 13f183f7c7
commit fce14f038e
11 changed files with 441 additions and 49 deletions

View File

@@ -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)