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:
@@ -488,3 +488,53 @@ git version 2.51.1.windows.1
|
|||||||
## Влияние на ближайшие работы
|
## Влияние на ближайшие работы
|
||||||
|
|
||||||
На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200–250 МГц вступает в силу начиная с эфирных испытаний.
|
На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200–250 МГц вступает в силу начиная с эфирных испытаний.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Запись 019
|
||||||
|
|
||||||
|
## Дата
|
||||||
|
|
||||||
|
10 августа 2026 года
|
||||||
|
|
||||||
|
## Тема
|
||||||
|
|
||||||
|
Lab042: первая передача изображения через реальный радиотракт. Эфирная проверка на паре антенн.
|
||||||
|
|
||||||
|
## Причина эфирного прогона
|
||||||
|
|
||||||
|
Кабельная петля требует переходника SMA папа-папа, которого пока нет: пигтейлы к антеннам оканчиваются папой, а разъёмы Pluto — мамы. Чтобы не простаивать, выполнена вспомогательная эфирная проверка на двух антеннах, разнесённых на 60 см. Она не заменяет кабельный опыт: уровни в помещении не контролируются строго, возможны переотражения. Её задача — снять последнюю неизвестность в коде, а именно обвязку вокруг устройства, которая в моделях не проверяется.
|
||||||
|
|
||||||
|
## Условия
|
||||||
|
|
||||||
|
- Устройство Analog Devices PlutoSDR Rev.C, чип AD9361, прошивка v0.32.
|
||||||
|
- Несущая 225 МГц, внутри целевого диапазона Записи 018.
|
||||||
|
- Антенны, рассчитанные на 108 МГц, работают на несущей как полуволновые. Частота передачи и частота, на которую рассчитана антенна, различны, поэтому в авиационный диапазон 108–137 МГц опыт не заходит.
|
||||||
|
- Изображение 320x180 в градациях серого, JPEG 4756 байт при качестве 35, десять фрагментов.
|
||||||
|
|
||||||
|
## Результат
|
||||||
|
|
||||||
|
- Изображение передано и собрано побайтово идентично исходному. Контрольная сумма MD5 переданного и принятого файла совпала.
|
||||||
|
- Десять фрагментов из десяти, ни одной несошедшейся контрольной суммы.
|
||||||
|
- Средняя нормированная корреляция при поиске кадра 0,99951.
|
||||||
|
- Средняя оценка ухода несущей минус 0,368 Гц, что отражает общий опорный генератор передатчика и приёмника.
|
||||||
|
- Канальная скорость 18,404 кбит/с при длительности сигнала в эфире около 2,07 секунды.
|
||||||
|
- Результат воспроизведён тремя запусками подряд с одинаковой контрольной суммой.
|
||||||
|
|
||||||
|
## Дефекты, вскрытые железом
|
||||||
|
|
||||||
|
Программная модель проходила чисто, но на устройстве обнаружились четыре дефекта, каждый воспроизведён и устранён.
|
||||||
|
|
||||||
|
- Кадр длиной 566 528 отсчётов не помещался в приёмный буфер 262 144. Между отдельными чтениями поток отсчётов не является непрерывным, и кадр на стыке разрушался. Приём переведён на один буфер, размер которого рассчитывается по длине кадра.
|
||||||
|
- Частотная коррекция применялась всегда, тогда как при общем опорном генераторе истинный уход близок к нулю. Оценка по маркеру длиной 80 символов состояла из шума, и коррекция им поворачивала фазу вдоль всего кадра: измерено 65 процентов ошибочных битов. Перенесена защита should_apply_cfo_correction из Lab023, разрешающая коррекцию только вне мёртвой зоны.
|
||||||
|
- Усиление приёмника 20 дБ давало сигнал около 0,0003 от шкалы АЦП. Рабочие уровни подобраны измерением: приёмник 50 дБ, передатчик минус 10 дБ дают ноль ошибочных битов с запасом.
|
||||||
|
- Величина вектора ошибки при неудачном приёме возвращала ноль, то есть показывала идеальный сигнал там, где сигнала не было. Это тот же класс ошибки, что вписанные показатели Lab041: метрика при отказе рапортует лучшее значение. Заменена на явный признак невычислимости.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Эфирный прогон является проверкой тракта, а не измерением: уровни не контролируются как в кабельной сборке.
|
||||||
|
- Потолок усиления передатчика в эфирном режиме рассчитывается из бюджета линии по расстоянию между антеннами, а не задан константой.
|
||||||
|
|
||||||
|
## Следующий этап
|
||||||
|
|
||||||
|
Кабельная петля через аттенюатор 30 дБ после получения переходника папа-папа. Это и есть заявленный опыт Lab042 с контролируемыми уровнями, его результат станет основным для лабораторной. Затем Lab043 — разведение на два устройства.
|
||||||
|
|||||||
BIN
data/processed/lab042/lab042_constellation.png
Normal file
BIN
data/processed/lab042/lab042_constellation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
data/processed/lab042/lab042_fragment_delivery.png
Normal file
BIN
data/processed/lab042/lab042_fragment_delivery.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
data/processed/lab042/lab042_frame_correlation.png
Normal file
BIN
data/processed/lab042/lab042_frame_correlation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
11
data/processed/lab042/lab042_frames.csv
Normal file
11
data/processed/lab042/lab042_frames.csv
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
index,packet_bytes,frame_bits,correlation_score,sample_phase,carrier_offset_hz,carrier_coherence,cfo_correction_applied,error_vector_magnitude,frame_found,header_parsed,crc_valid,fragment_recovered
|
||||||
|
0,536,4384,0.9995083676316812,15,-0.8161804653898344,0.9999521460387033,False,0.013939054015288657,True,True,True,True
|
||||||
|
1,536,4384,0.9994247234007716,49,0.034058565997241885,0.9999449532988285,False,0.007533311365459869,True,True,True,True
|
||||||
|
2,536,4384,0.9995067920215722,121,-0.4024654150100275,0.9999698768639075,False,0.007802389734928202,True,True,True,True
|
||||||
|
3,536,4384,0.9995973624847003,79,0.05360429263743072,0.9999563681724133,False,0.008067411996806204,True,True,True,True
|
||||||
|
4,536,4384,0.9995573203179389,37,-0.7726047795897841,0.999951969069221,False,0.008830283803028178,True,True,True,True
|
||||||
|
5,536,4384,0.9994972026358666,35,0.14518673176235097,0.9999740428064059,False,0.007829934600060385,True,True,True,True
|
||||||
|
6,536,4384,0.9994993201100448,33,-0.6575975246787777,0.9999581020309138,False,0.008192298206454165,True,True,True,True
|
||||||
|
7,536,4384,0.9995337736352732,97,-0.32146831121434616,0.9999654759558527,False,0.00773812855638587,True,True,True,True
|
||||||
|
8,536,4384,0.9995377136427829,90,-0.32692415971800415,0.9999706666958124,False,0.008197030505111991,True,True,True,True
|
||||||
|
9,172,1472,0.9994478378475372,53,-0.6175162038574342,0.9999730687021144,False,0.011683449034017782,True,True,True,True
|
||||||
|
BIN
data/processed/lab042/lab042_received.jpg
Normal file
BIN
data/processed/lab042/lab042_received.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
87
data/processed/lab042/lab042_report.txt
Normal file
87
data/processed/lab042/lab042_report.txt
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
Lab042 — передача JPEG через кабельную петлю на одном PlutoSDR
|
||||||
|
|
||||||
|
Режим прогона
|
||||||
|
- Канал: air.
|
||||||
|
- Трасса: две антенны по воздуху, расстояние 60 см, аттенюатора нет.
|
||||||
|
- Несущая 225.000 МГц — внутри целевого диапазона 200-250 МГц, закреплённого Записью 018.
|
||||||
|
- Усиление передатчика -10.0 дБ, приёмника 50.0 дБ, режим ручной.
|
||||||
|
- Антенны разнесены на 60 см. Аттенюатора в цепи нет: приёмник защищён ослаблением на трассе.
|
||||||
|
- Использованы штыри, рассчитанные на 108 МГц. На рабочей частоте они работают как полуволновые, согласование плохое, но для этой дистанции достаточное.
|
||||||
|
- Уровни в эфирной трассе не контролируются так же строго, как в кабельной: возможны переотражения от предметов в помещении. Прогон служит проверкой тракта устройства, а не измерением.
|
||||||
|
|
||||||
|
Параметры тракта
|
||||||
|
- Символьная скорость 20000 симв/с, отсчётов на символ 128.
|
||||||
|
- Частота дискретизации 2560000 Гц. Минимум устройства 2083333 Гц.
|
||||||
|
- Скругление 0.35, длина фильтра 10 символов, защитный интервал 16 символов.
|
||||||
|
- Значения перенесены из Lab018 без изменений, кроме передискретизации: она поднята с 32 до 128,
|
||||||
|
потому что частота 640 000 Гц из Lab018 ниже предела устройства втрое.
|
||||||
|
|
||||||
|
Передаваемое изображение
|
||||||
|
- Источник data/raw/lab011_scene.jpg, 320x180, качество 30.
|
||||||
|
- Размер JPEG 4756 байт.
|
||||||
|
- Фрагментов 10 по 512 байт данных.
|
||||||
|
- Пакет протокола от 172 до 536 байт, радиокадр от 1472 до 4384 бит.
|
||||||
|
- Последний фрагмент изображения короче остальных, поэтому его кадр меньше. Размер каждого кадра приведён в lab042_frames.csv.
|
||||||
|
- Всего передано 5292544 отсчётов, что соответствует 2.067 с эфира.
|
||||||
|
|
||||||
|
Результат приёма
|
||||||
|
- Кадров найдено 10 из 10.
|
||||||
|
- Кадров с несошедшейся CRC32: 0.
|
||||||
|
- Фрагментов восстановлено 10, доля 100.000%.
|
||||||
|
- Изображение собрано: да.
|
||||||
|
- Совпадает с переданным побайтово: да.
|
||||||
|
- Размер принятого изображения 4756 байт.
|
||||||
|
|
||||||
|
Качество сигнала
|
||||||
|
- Средняя нормированная корреляция при поиске кадра 0.99951.
|
||||||
|
- Средняя оценка ухода несущей -0.368 Гц.
|
||||||
|
- Средняя величина вектора ошибки 0.898%.
|
||||||
|
|
||||||
|
Скорость
|
||||||
|
- Длительность сигнала в эфире 2.067 с.
|
||||||
|
- Канальная скорость 18.404 кбит/с — полезные байты, делённые на время эфира.
|
||||||
|
- Полное время работы программы 16.638 с, что даёт 2.287 кбит/с.
|
||||||
|
- В программном режиме вторая величина отражает быстродействие вычислений, а не пропускную способность канала.
|
||||||
|
|
||||||
|
Кадры
|
||||||
|
номер | корреляция | фаза | уход несущей, Гц | достоверность оценки | вектор ошибки, % | найден | заголовок | CRC32 | фрагмент
|
||||||
|
0 | 0.99951 | 15 | -0.816 | 0.99995 | 1.394 | да | да | да | да
|
||||||
|
1 | 0.99942 | 49 | 0.034 | 0.99994 | 0.753 | да | да | да | да
|
||||||
|
2 | 0.99951 | 121 | -0.402 | 0.99997 | 0.780 | да | да | да | да
|
||||||
|
3 | 0.99960 | 79 | 0.054 | 0.99996 | 0.807 | да | да | да | да
|
||||||
|
4 | 0.99956 | 37 | -0.773 | 0.99995 | 0.883 | да | да | да | да
|
||||||
|
5 | 0.99950 | 35 | 0.145 | 0.99997 | 0.783 | да | да | да | да
|
||||||
|
6 | 0.99950 | 33 | -0.658 | 0.99996 | 0.819 | да | да | да | да
|
||||||
|
7 | 0.99953 | 97 | -0.321 | 0.99997 | 0.774 | да | да | да | да
|
||||||
|
8 | 0.99954 | 90 | -0.327 | 0.99997 | 0.820 | да | да | да | да
|
||||||
|
9 | 0.99945 | 53 | -0.618 | 0.99997 | 1.168 | да | да | да | да
|
||||||
|
|
||||||
|
Функциональные проверки
|
||||||
|
- ПРОЙДЕНО — 01. Размер подготовленного JPEG: JPEG занимает 4756 байт при пределе 5000, качество 30
|
||||||
|
- ПРОЙДЕНО — 02. Разбиение и сборка изображения: 10 фрагментов собираются в исходные байты
|
||||||
|
- ПРОЙДЕНО — 03. Кадр несёт пакет с CRC32: все 10 кадров несут пакет с сошедшейся CRC32
|
||||||
|
- ПРОЙДЕНО — 04. Длина сформированного кадра: кадр занимает 566528 отсчётов, что совпадает с расчётом
|
||||||
|
- ПРОЙДЕНО — 05. Масштабирование в целые отсчёты: пиковая амплитуда 1601 не превышает предела 16384
|
||||||
|
- ПРОЙДЕНО — 06. Программная петля восстанавливает изображение: программная петля вернула 4756 байт, идентичных исходным
|
||||||
|
- ПРОЙДЕНО — 07. Обнаружение искажённого кадра: одиночная битовая ошибка в данных кадра обнаружена контрольной суммой
|
||||||
|
- ПРОЙДЕНО — 08. Потеря фрагмента: потеря фрагмента не портит остальные, сборка требует всех
|
||||||
|
- ПРОЙДЕНО — 09. Достижимость частоты дискретизации: 2560000 Гц не ниже предела устройства 2083333 Гц
|
||||||
|
- ПРОЙДЕНО — 10. Настройки устройства в пределах: усиления, полоса 200000 Гц и несущая 435000000 Гц внутри допустимых пределов, сигнал занимает 27000 Гц
|
||||||
|
- ПРОЙДЕНО — 11. Потолок усиления в эфирном режиме: предел зависит от расстояния: -19.5 дБ на 5 см против 6.0 дБ на 200 см
|
||||||
|
|
||||||
|
Ограничения
|
||||||
|
- Передатчик и приёмник принадлежат одному устройству и работают от общего опорного
|
||||||
|
генератора, поэтому уход несущей между ними мал и не характеризует реальную линию.
|
||||||
|
- Повторная передача потерянных фрагментов не выполняется: потери фиксируются, но не восстанавливаются.
|
||||||
|
- Исправление стираний не подключалось.
|
||||||
|
- Разведение на два устройства и выход в эфир — предмет Lab043 и Lab044.
|
||||||
|
|
||||||
|
Созданные файлы
|
||||||
|
- data/processed/lab042/lab042_received.jpg
|
||||||
|
- data/processed/lab042/lab042_summary.csv
|
||||||
|
- data/processed/lab042/lab042_frames.csv
|
||||||
|
- data/processed/lab042/lab042_report.txt
|
||||||
|
- data/processed/lab042/lab042_constellation.png
|
||||||
|
- data/processed/lab042/lab042_spectrum.png
|
||||||
|
- data/processed/lab042/lab042_frame_correlation.png
|
||||||
|
- data/processed/lab042/lab042_fragment_delivery.png
|
||||||
BIN
data/processed/lab042/lab042_spectrum.png
Normal file
BIN
data/processed/lab042/lab042_spectrum.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
2
data/processed/lab042/lab042_summary.csv
Normal file
2
data/processed/lab042/lab042_summary.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
loopback_mode,path_description,antenna_distance_cm,source_image,image_width,image_height,jpeg_quality,jpeg_bytes,fragment_data_size,fragment_count,packet_bytes_max,packet_bytes_min,frame_bits_max,frame_bits_min,symbol_rate,samples_per_symbol,sample_rate_hz,carrier_hz,transmitted_samples,air_time_seconds,frames_received,frames_failed_crc,fragments_recovered,fragment_delivery_fraction,image_reassembled,image_identical,received_image_bytes,transfer_seconds,channel_bitrate_kbps,wall_clock_bitrate_kbps,mean_correlation_score,mean_carrier_offset_hz,mean_error_vector_magnitude,tx_gain_db,rx_gain_db,attenuator_db
|
||||||
|
air,"две антенны по воздуху, расстояние 60 см, аттенюатора нет",60.0,data/raw/lab011_scene.jpg,320,180,30,4756,512,10,536,172,4384,1472,20000,128,2560000,225000000,5292544,2.0674,10,0,10,1.0,True,True,4756,16.637855200009653,18.40379220276676,2.286833221146072,0.9995110413728169,-0.3681907269061185,0.00898132918175413,-10.0,50.0,0.0
|
||||||
|
@@ -4,20 +4,31 @@
|
|||||||
весь тракт был собран и проверен на моделях, но ни одного вызова передачи
|
весь тракт был собран и проверен на моделях, но ни одного вызова передачи
|
||||||
через SDR в проекте не было.
|
через SDR в проекте не было.
|
||||||
|
|
||||||
Схема опыта:
|
|
||||||
|
|
||||||
выход передатчика → аттенюатор 30 дБ → вход приёмника
|
|
||||||
|
|
||||||
Передатчик и приёмник принадлежат одному устройству и работают от общего
|
Передатчик и приёмник принадлежат одному устройству и работают от общего
|
||||||
опорного генератора, поэтому ухода несущей между ними практически нет.
|
опорного генератора, поэтому ухода несущей между ними практически нет.
|
||||||
Разведение на два устройства — предмет Lab043.
|
Разведение на два устройства — предмет Lab043.
|
||||||
|
|
||||||
Лабораторная работает в двух режимах. Программный замыкает канал в памяти
|
Три режима трассы:
|
||||||
и оборудования не требует; он служит эталоном, с которым сравнивается
|
|
||||||
аппаратный прогон. Разность между режимами и есть вклад радиотракта.
|
software канал замкнут в памяти, оборудование не нужно. Служит
|
||||||
|
эталоном: всё, чем отличается аппаратный прогон, внесено
|
||||||
|
радиотрактом.
|
||||||
|
|
||||||
|
cable выход передатчика → аттенюатор 30 дБ → вход приёмника.
|
||||||
|
Основной опыт лабораторной: уровни известны, посторонних
|
||||||
|
путей распространения нет.
|
||||||
|
|
||||||
|
air две антенны по воздуху на малом расстоянии, аттенюатора нет.
|
||||||
|
Вспомогательный режим для проверки тракта устройства, когда
|
||||||
|
кабельная сборка недоступна. Уровни контролируются хуже,
|
||||||
|
возможны переотражения, поэтому измерением он не считается.
|
||||||
|
|
||||||
python -m experiments.lab042_pluto_image_loopback --loopback software
|
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
|
Постановка задачи: docs/lab042_hardware_loopback_spec.md
|
||||||
"""
|
"""
|
||||||
@@ -28,6 +39,7 @@ import argparse
|
|||||||
import csv
|
import csv
|
||||||
import functools
|
import functools
|
||||||
import io
|
import io
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -74,13 +86,41 @@ DEVICE_MINIMUM_SAMPLE_RATE_HZ = 2_083_333
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- параметры устройства
|
# ---------------------------------------------------------------- параметры устройства
|
||||||
PLUTO_URI = "ip:192.168.2.1"
|
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
|
RF_BANDWIDTH_HZ = 200_000
|
||||||
TX_GAIN_DB = -30.0
|
TX_GAIN_DB = -30.0
|
||||||
# Диапазон усиления приёмника получен опросом устройства 10 августа 2026
|
# Диапазон усиления приёмника получен опросом устройства 10 августа 2026
|
||||||
# года: hardwaregain_available = [-3 1 71] дБ в ручном режиме.
|
# года: hardwaregain_available = [-3 1 71] дБ в ручном режиме.
|
||||||
RX_GAIN_DB = 20.0
|
RX_GAIN_DB = 20.0
|
||||||
RX_BUFFER_SIZE = 262_144
|
# Приёмный буфер подбирается под длину кадра: отдельные чтения не дают
|
||||||
|
# непрерывного потока отсчётов, и кадр, растянутый на несколько буферов,
|
||||||
|
# рвётся на стыках. Измерено: кадр 566 528 отсчётов при буфере 262 144
|
||||||
|
# находился по преамбуле, но данные за ней приходили испорченными.
|
||||||
|
RX_BUFFER_LIMIT = 1 << 22
|
||||||
# Ослабление внешнего аттенюатора AT30S, включённого в разрыв тракта.
|
# Ослабление внешнего аттенюатора AT30S, включённого в разрыв тракта.
|
||||||
ATTENUATOR_DB = 30.0
|
ATTENUATOR_DB = 30.0
|
||||||
# PlutoSDR принимает целые отсчёты. Множитель приводит сигнал с амплитудой
|
# PlutoSDR принимает целые отсчёты. Множитель приводит сигнал с амплитудой
|
||||||
@@ -135,6 +175,7 @@ class FrameResult:
|
|||||||
sample_phase: int
|
sample_phase: int
|
||||||
carrier_offset_hz: float
|
carrier_offset_hz: float
|
||||||
carrier_coherence: float
|
carrier_coherence: float
|
||||||
|
cfo_correction_applied: bool
|
||||||
error_vector_magnitude: float
|
error_vector_magnitude: float
|
||||||
frame_found: bool
|
frame_found: bool
|
||||||
header_parsed: bool
|
header_parsed: bool
|
||||||
@@ -147,6 +188,8 @@ class TransferMetrics:
|
|||||||
"""Сводка одного прогона."""
|
"""Сводка одного прогона."""
|
||||||
|
|
||||||
loopback_mode: str
|
loopback_mode: str
|
||||||
|
path_description: str
|
||||||
|
antenna_distance_cm: float
|
||||||
source_image: str
|
source_image: str
|
||||||
image_width: int
|
image_width: int
|
||||||
image_height: int
|
image_height: int
|
||||||
@@ -299,7 +342,46 @@ def software_channel(waveform: np.ndarray) -> np.ndarray:
|
|||||||
return waveform.copy()
|
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 и настроить его для кабельной петли.
|
"""Открыть PlutoSDR и настроить его для кабельной петли.
|
||||||
|
|
||||||
Настройки выбраны так, чтобы приёмник не мог быть повреждён: с
|
Настройки выбраны так, чтобы приёмник не мог быть повреждён: с
|
||||||
@@ -311,16 +393,16 @@ def open_device():
|
|||||||
|
|
||||||
device = adi.Pluto(uri=PLUTO_URI)
|
device = adi.Pluto(uri=PLUTO_URI)
|
||||||
device.sample_rate = int(SAMPLE_RATE_HZ)
|
device.sample_rate = int(SAMPLE_RATE_HZ)
|
||||||
device.tx_lo = int(CARRIER_HZ)
|
device.tx_lo = int(carrier_hz)
|
||||||
device.rx_lo = int(CARRIER_HZ)
|
device.rx_lo = int(carrier_hz)
|
||||||
device.tx_rf_bandwidth = int(RF_BANDWIDTH_HZ)
|
device.tx_rf_bandwidth = int(RF_BANDWIDTH_HZ)
|
||||||
device.rx_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.gain_control_mode_chan0 = "manual"
|
||||||
device.rx_hardwaregain_chan0 = float(RX_GAIN_DB)
|
device.rx_hardwaregain_chan0 = float(rx_gain_db)
|
||||||
device.rx_buffer_size = int(RX_BUFFER_SIZE)
|
device.rx_buffer_size = int(rx_buffer_size)
|
||||||
# Циклический буфер повторял бы кадр бесконечно: передача не
|
# Циклический буфер повторял бы кадр бесконечно: передача не
|
||||||
# заканчивалась бы, а приёмник видел тысячи копий одного кадра.
|
# заканчивалась бы, а приёмник видел тысячи копий одного кадра.
|
||||||
device.tx_cyclic_buffer = False
|
device.tx_cyclic_buffer = False
|
||||||
@@ -328,9 +410,13 @@ def open_device():
|
|||||||
|
|
||||||
|
|
||||||
def hardware_transfer(device, frame_waveform: np.ndarray) -> np.ndarray:
|
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_destroy_buffer()
|
||||||
device.tx(to_device_samples(frame_waveform))
|
device.tx(to_device_samples(frame_waveform))
|
||||||
|
|
||||||
chunks: list[np.ndarray] = []
|
segment = np.asarray(device.rx(), dtype=np.complex64)
|
||||||
collected = 0
|
|
||||||
while collected < needed_samples:
|
|
||||||
chunk = np.asarray(device.rx(), dtype=np.complex64)
|
|
||||||
chunks.append(chunk)
|
|
||||||
collected += len(chunk)
|
|
||||||
|
|
||||||
device.tx_destroy_buffer()
|
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
|
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)
|
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_offset_hz = float(estimate["estimated_frequency_hz"])
|
||||||
carrier_coherence = float(estimate["cfo_coherence"])
|
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)
|
error_vector = _error_vector_magnitude(corrected, plan.symbols)
|
||||||
|
|
||||||
packet = radio.parse_radio_frame(radio.bpsk_demodulate(corrected))
|
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"]),
|
sample_phase=int(found["sample_phase"]),
|
||||||
carrier_offset_hz=carrier_offset_hz,
|
carrier_offset_hz=carrier_offset_hz,
|
||||||
carrier_coherence=carrier_coherence,
|
carrier_coherence=carrier_coherence,
|
||||||
|
cfo_correction_applied=cfo_applied,
|
||||||
error_vector_magnitude=error_vector,
|
error_vector_magnitude=error_vector,
|
||||||
frame_found=True,
|
frame_found=True,
|
||||||
header_parsed=header_parsed,
|
header_parsed=header_parsed,
|
||||||
@@ -412,10 +510,17 @@ def receive_frame(segment: np.ndarray, plan: FramePlan, taps: np.ndarray) -> tup
|
|||||||
return result, payload
|
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:
|
def _failed_result(plan: FramePlan, correlation: float = 0.0, phase: int = 0) -> FrameResult:
|
||||||
return FrameResult(
|
return FrameResult(
|
||||||
plan.index, len(plan.packet), len(plan.bits),
|
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):
|
if not len(received) or len(received) != len(reference):
|
||||||
return 0.0
|
return float("nan")
|
||||||
|
|
||||||
scale = np.vdot(reference, received) / np.vdot(reference, reference)
|
scale = np.vdot(reference, received) / np.vdot(reference, reference)
|
||||||
difference = received - scale * reference
|
difference = received - scale * reference
|
||||||
reference_power = float(np.mean(np.abs(reference) ** 2))
|
reference_power = float(np.mean(np.abs(reference) ** 2))
|
||||||
if reference_power <= 0.0:
|
if reference_power <= 0.0:
|
||||||
return 0.0
|
return float("nan")
|
||||||
return float(np.sqrt(np.mean(np.abs(difference) ** 2) / reference_power))
|
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()
|
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]
|
waveforms = [shape_frame(plan.symbols, taps) for plan in plans]
|
||||||
|
|
||||||
device = None
|
device = None
|
||||||
if loopback_mode == "hardware":
|
if loopback_mode in ("cable", "air"):
|
||||||
device = open_device()
|
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()
|
started = time.monotonic()
|
||||||
results: list[FrameResult] = []
|
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)
|
recovered = sum(1 for item in results if item.fragment_recovered)
|
||||||
successful = [item for item in results if item.frame_found]
|
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(
|
metrics = TransferMetrics(
|
||||||
loopback_mode=loopback_mode,
|
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(),
|
source_image=SOURCE_IMAGE.as_posix(),
|
||||||
image_width=IMAGE_WIDTH,
|
image_width=IMAGE_WIDTH,
|
||||||
image_height=IMAGE_HEIGHT,
|
image_height=IMAGE_HEIGHT,
|
||||||
@@ -504,7 +654,7 @@ def run_transfer(loopback_mode: str) -> tuple[TransferMetrics, tuple[FrameResult
|
|||||||
symbol_rate=SYMBOL_RATE,
|
symbol_rate=SYMBOL_RATE,
|
||||||
samples_per_symbol=SAMPLES_PER_SYMBOL,
|
samples_per_symbol=SAMPLES_PER_SYMBOL,
|
||||||
sample_rate_hz=SAMPLE_RATE_HZ,
|
sample_rate_hz=SAMPLE_RATE_HZ,
|
||||||
carrier_hz=CARRIER_HZ,
|
carrier_hz=carrier_hz,
|
||||||
transmitted_samples=transmitted_samples,
|
transmitted_samples=transmitted_samples,
|
||||||
air_time_seconds=air_time,
|
air_time_seconds=air_time,
|
||||||
frames_received=len(successful),
|
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,
|
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_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_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,
|
mean_error_vector_magnitude=_mean_finite([item.error_vector_magnitude for item in successful]),
|
||||||
tx_gain_db=TX_GAIN_DB if loopback_mode == "hardware" else 0.0,
|
tx_gain_db=tx_gain_db if loopback_mode != "software" else 0.0,
|
||||||
rx_gain_db=RX_GAIN_DB if loopback_mode == "hardware" else 0.0,
|
rx_gain_db=rx_gain_db if loopback_mode != "software" else 0.0,
|
||||||
attenuator_db=ATTENUATOR_DB if loopback_mode == "hardware" else 0.0,
|
attenuator_db=ATTENUATOR_DB if loopback_mode == "cable" else 0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
return metrics, tuple(results), received_image, last_segment
|
return metrics, tuple(results), received_image, last_segment
|
||||||
@@ -632,6 +782,18 @@ def run_functional_tests() -> tuple[FunctionalTestResult, ...]:
|
|||||||
f"внутри допустимых пределов, сигнал занимает {signal_bandwidth:.0f} Гц"
|
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 = (
|
checks = (
|
||||||
("01. Размер подготовленного JPEG", jpeg_fits_the_limit),
|
("01. Размер подготовленного JPEG", jpeg_fits_the_limit),
|
||||||
("02. Разбиение и сборка изображения", fragments_roundtrip),
|
("02. Разбиение и сборка изображения", fragments_roundtrip),
|
||||||
@@ -643,6 +805,7 @@ def run_functional_tests() -> tuple[FunctionalTestResult, ...]:
|
|||||||
("08. Потеря фрагмента", lost_fragment_does_not_spoil_the_rest),
|
("08. Потеря фрагмента", lost_fragment_does_not_spoil_the_rest),
|
||||||
("09. Достижимость частоты дискретизации", sample_rate_is_reachable),
|
("09. Достижимость частоты дискретизации", sample_rate_is_reachable),
|
||||||
("10. Настройки устройства в пределах", device_settings_are_within_limits),
|
("10. Настройки устройства в пределах", device_settings_are_within_limits),
|
||||||
|
("11. Потолок усиления в эфирном режиме", air_gain_ceiling_is_enforced),
|
||||||
)
|
)
|
||||||
|
|
||||||
for name, function in checks:
|
for name, function in checks:
|
||||||
@@ -737,15 +900,26 @@ def write_report(
|
|||||||
f"- Канал: {metrics.loopback_mode}.",
|
f"- Канал: {metrics.loopback_mode}.",
|
||||||
]
|
]
|
||||||
|
|
||||||
if metrics.loopback_mode == "hardware":
|
lines.append(f"- Трасса: {metrics.path_description}.")
|
||||||
|
|
||||||
|
if metrics.loopback_mode == "cable":
|
||||||
lines.extend(
|
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} дБ, режим ручной.",
|
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:
|
else:
|
||||||
lines.append("- Оборудование не использовалось: канал замкнут в памяти. Прогон служит эталоном.")
|
lines.append("- Оборудование не использовалось: канал замкнут в памяти. Прогон служит эталоном.")
|
||||||
|
|
||||||
@@ -827,7 +1001,7 @@ def write_report(
|
|||||||
def validate_outputs(metrics: TransferMetrics, results: tuple[FrameResult, ...], tests: tuple[FunctionalTestResult, ...]) -> None:
|
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
|
assert len(results) == metrics.fragment_count
|
||||||
|
|
||||||
for path in (SUMMARY_CSV, FRAMES_CSV):
|
for path in (SUMMARY_CSV, FRAMES_CSV):
|
||||||
@@ -855,16 +1029,45 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser(description="Lab042: передача JPEG через кабельную петлю")
|
parser = argparse.ArgumentParser(description="Lab042: передача JPEG через кабельную петлю")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--loopback",
|
"--loopback",
|
||||||
choices=("software", "hardware"),
|
choices=("software", "cable", "air"),
|
||||||
default="software",
|
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()
|
arguments = parser.parse_args()
|
||||||
|
|
||||||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Файлы прошлого прогона удаляются: иначе принятое изображение от
|
||||||
|
# удачного запуска осталось бы лежать рядом с результатами неудачного.
|
||||||
|
for stale in OUTPUT_DIRECTORY.glob("*"):
|
||||||
|
if stale.is_file():
|
||||||
|
stale.unlink()
|
||||||
|
|
||||||
tests = run_functional_tests()
|
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:
|
if received_image is not None:
|
||||||
RECEIVED_IMAGE_PATH.write_bytes(received_image)
|
RECEIVED_IMAGE_PATH.write_bytes(received_image)
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ SYMBOL_RATE = 20_000
|
|||||||
CFO_REFINEMENT_HALF_WIDTH_HZ = 200.0
|
CFO_REFINEMENT_HALF_WIDTH_HZ = 200.0
|
||||||
CFO_REFINEMENT_STEP_HZ = 1.0
|
CFO_REFINEMENT_STEP_HZ = 1.0
|
||||||
|
|
||||||
|
# Пороги разрешения частотной коррекции. Значения из Lab023. Смысл в том,
|
||||||
|
# что при малом истинном уходе оценка по короткому маркеру состоит почти
|
||||||
|
# целиком из шума, и коррекция таким значением портит длинный кадр сильнее,
|
||||||
|
# чем отсутствие коррекции вообще.
|
||||||
|
CFO_DEAD_ZONE_HZ = 15.0
|
||||||
|
MINIMUM_PHASE_CONSISTENCY = 0.55
|
||||||
|
|
||||||
MAXIMUM_PROTOCOL_PACKET_BYTES = 4096
|
MAXIMUM_PROTOCOL_PACKET_BYTES = 4096
|
||||||
|
|
||||||
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
|
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
|
||||||
@@ -800,3 +807,35 @@ def radio_frame_bit_count(protocol_packet_bytes: int) -> int:
|
|||||||
+ RADIO_HEADER_BIT_COUNT
|
+ RADIO_HEADER_BIT_COUNT
|
||||||
+ protocol_packet_bytes * 8
|
+ protocol_packet_bytes * 8
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Перенесено дословно из Lab023 (experiments/lab023_guarded_cfo_correction.py).
|
||||||
|
def should_apply_cfo_correction(
|
||||||
|
estimated_frequency_hz: float,
|
||||||
|
phase_consistency: float,
|
||||||
|
coherence_gain: float,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Разрешить CFO-коррекцию только при наличии
|
||||||
|
достаточных оснований.
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
|
||||||
|
1. Оценка находится вне мёртвой зоны.
|
||||||
|
2. Межсимвольное вращение достаточно согласованно.
|
||||||
|
3. Компенсация CFO действительно повышает
|
||||||
|
когерентность известного маркера.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MINIMUM_COHERENCE_GAIN = 0.02
|
||||||
|
|
||||||
|
return (
|
||||||
|
abs(
|
||||||
|
estimated_frequency_hz
|
||||||
|
)
|
||||||
|
>= CFO_DEAD_ZONE_HZ
|
||||||
|
and phase_consistency
|
||||||
|
>= MINIMUM_PHASE_CONSISTENCY
|
||||||
|
and coherence_gain
|
||||||
|
>= MINIMUM_COHERENCE_GAIN
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user