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

@@ -488,3 +488,53 @@ git version 2.51.1.windows.1
## Влияние на ближайшие работы ## Влияние на ближайшие работы
На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200250 МГц вступает в силу начиная с эфирных испытаний. На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200250 МГц вступает в силу начиная с эфирных испытаний.
---
# Запись 019
## Дата
10 августа 2026 года
## Тема
Lab042: первая передача изображения через реальный радиотракт. Эфирная проверка на паре антенн.
## Причина эфирного прогона
Кабельная петля требует переходника SMA папа-папа, которого пока нет: пигтейлы к антеннам оканчиваются папой, а разъёмы Pluto — мамы. Чтобы не простаивать, выполнена вспомогательная эфирная проверка на двух антеннах, разнесённых на 60 см. Она не заменяет кабельный опыт: уровни в помещении не контролируются строго, возможны переотражения. Её задача — снять последнюю неизвестность в коде, а именно обвязку вокруг устройства, которая в моделях не проверяется.
## Условия
- Устройство Analog Devices PlutoSDR Rev.C, чип AD9361, прошивка v0.32.
- Несущая 225 МГц, внутри целевого диапазона Записи 018.
- Антенны, рассчитанные на 108 МГц, работают на несущей как полуволновые. Частота передачи и частота, на которую рассчитана антенна, различны, поэтому в авиационный диапазон 108137 МГц опыт не заходит.
- Изображение 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 — разведение на два устройства.

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View 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
1 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
2 0 536 4384 0.9995083676316812 15 -0.8161804653898344 0.9999521460387033 False 0.013939054015288657 True True True True
3 1 536 4384 0.9994247234007716 49 0.034058565997241885 0.9999449532988285 False 0.007533311365459869 True True True True
4 2 536 4384 0.9995067920215722 121 -0.4024654150100275 0.9999698768639075 False 0.007802389734928202 True True True True
5 3 536 4384 0.9995973624847003 79 0.05360429263743072 0.9999563681724133 False 0.008067411996806204 True True True True
6 4 536 4384 0.9995573203179389 37 -0.7726047795897841 0.999951969069221 False 0.008830283803028178 True True True True
7 5 536 4384 0.9994972026358666 35 0.14518673176235097 0.9999740428064059 False 0.007829934600060385 True True True True
8 6 536 4384 0.9994993201100448 33 -0.6575975246787777 0.9999581020309138 False 0.008192298206454165 True True True True
9 7 536 4384 0.9995337736352732 97 -0.32146831121434616 0.9999654759558527 False 0.00773812855638587 True True True True
10 8 536 4384 0.9995377136427829 90 -0.32692415971800415 0.9999706666958124 False 0.008197030505111991 True True True True
11 9 172 1472 0.9994478378475372 53 -0.6175162038574342 0.9999730687021144 False 0.011683449034017782 True True True True

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View 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
1 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
2 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

View File

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

View File

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