diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index 4256814..95fa49d 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -488,3 +488,53 @@ git version 2.51.1.windows.1 ## Влияние на ближайшие работы На 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 — разведение на два устройства. diff --git a/data/processed/lab042/lab042_constellation.png b/data/processed/lab042/lab042_constellation.png new file mode 100644 index 0000000..0376d9e Binary files /dev/null and b/data/processed/lab042/lab042_constellation.png differ diff --git a/data/processed/lab042/lab042_fragment_delivery.png b/data/processed/lab042/lab042_fragment_delivery.png new file mode 100644 index 0000000..724afc4 Binary files /dev/null and b/data/processed/lab042/lab042_fragment_delivery.png differ diff --git a/data/processed/lab042/lab042_frame_correlation.png b/data/processed/lab042/lab042_frame_correlation.png new file mode 100644 index 0000000..dab8fc8 Binary files /dev/null and b/data/processed/lab042/lab042_frame_correlation.png differ diff --git a/data/processed/lab042/lab042_frames.csv b/data/processed/lab042/lab042_frames.csv new file mode 100644 index 0000000..874f698 --- /dev/null +++ b/data/processed/lab042/lab042_frames.csv @@ -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 diff --git a/data/processed/lab042/lab042_received.jpg b/data/processed/lab042/lab042_received.jpg new file mode 100644 index 0000000..762c1fa Binary files /dev/null and b/data/processed/lab042/lab042_received.jpg differ diff --git a/data/processed/lab042/lab042_report.txt b/data/processed/lab042/lab042_report.txt new file mode 100644 index 0000000..d48a206 --- /dev/null +++ b/data/processed/lab042/lab042_report.txt @@ -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 diff --git a/data/processed/lab042/lab042_spectrum.png b/data/processed/lab042/lab042_spectrum.png new file mode 100644 index 0000000..b6fbb17 Binary files /dev/null and b/data/processed/lab042/lab042_spectrum.png differ diff --git a/data/processed/lab042/lab042_summary.csv b/data/processed/lab042/lab042_summary.csv new file mode 100644 index 0000000..a0e2790 --- /dev/null +++ b/data/processed/lab042/lab042_summary.csv @@ -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 diff --git a/experiments/lab042_pluto_image_loopback.py b/experiments/lab042_pluto_image_loopback.py index b4384ca..9e2c617 100644 --- a/experiments/lab042_pluto_image_loopback.py +++ b/experiments/lab042_pluto_image_loopback.py @@ -4,20 +4,31 @@ весь тракт был собран и проверен на моделях, но ни одного вызова передачи через SDR в проекте не было. -Схема опыта: - - выход передатчика → аттенюатор 30 дБ → вход приёмника - Передатчик и приёмник принадлежат одному устройству и работают от общего опорного генератора, поэтому ухода несущей между ними практически нет. Разведение на два устройства — предмет Lab043. -Лабораторная работает в двух режимах. Программный замыкает канал в памяти -и оборудования не требует; он служит эталоном, с которым сравнивается -аппаратный прогон. Разность между режимами и есть вклад радиотракта. +Три режима трассы: + + software канал замкнут в памяти, оборудование не нужно. Служит + эталоном: всё, чем отличается аппаратный прогон, внесено + радиотрактом. + + cable выход передатчика → аттенюатор 30 дБ → вход приёмника. + Основной опыт лабораторной: уровни известны, посторонних + путей распространения нет. + + air две антенны по воздуху на малом расстоянии, аттенюатора нет. + Вспомогательный режим для проверки тракта устройства, когда + кабельная сборка недоступна. Уровни контролируются хуже, + возможны переотражения, поэтому измерением он не считается. python -m experiments.lab042_pluto_image_loopback --loopback software - python -m experiments.lab042_pluto_image_loopback --loopback hardware + python -m experiments.lab042_pluto_image_loopback --loopback cable + python -m experiments.lab042_pluto_image_loopback --loopback air --antenna-distance-cm 30 + +Несущая по умолчанию: 435 МГц для кабеля, где она произвольна, и 225 МГц +для эфира — внутри целевого диапазона 200-250 МГц из Записи 018 журнала. Постановка задачи: docs/lab042_hardware_loopback_spec.md """ @@ -28,6 +39,7 @@ import argparse import csv import functools import io +import math import time from dataclasses import asdict, dataclass, field from pathlib import Path @@ -74,13 +86,41 @@ DEVICE_MINIMUM_SAMPLE_RATE_HZ = 2_083_333 # ---------------------------------------------------------------- параметры устройства PLUTO_URI = "ip:192.168.2.1" -CARRIER_HZ = 435_000_000 +# Кабельная петля: несущая произвольна, сигнал в эфир не выходит. +CABLE_CARRIER_HZ = 435_000_000 +# Эфирная петля: несущая взята внутри целевого диапазона проекта +# 200-250 МГц, закреплённого Записью 018 журнала. Это заодно первый +# практический опыт работы в выбранной полосе. +AIR_CARRIER_HZ = 225_000_000 +CARRIER_HZ = CABLE_CARRIER_HZ +# Данные для расчёта безопасного уровня в эфирной петле. +PLUTO_MAXIMUM_OUTPUT_DBM = 7.0 +RECEIVER_DAMAGE_THRESHOLD_DBM = 2.5 +RECEIVER_SAFETY_MARGIN_DB = 15.0 +# Усиление антенн принято нулевым: штыри на 108 МГц работают вне расчётной +# частоты, и завышать их вклад в сторону безопасности нельзя. +ASSUMED_ANTENNA_GAIN_DBI = 0.0 + +# Рабочие уровни для эфира подобраны измерением 10 августа 2026 года: +# трасса 60 см, несущая 225 МГц. При усилении приёмника 20 дБ доля +# ошибочных битов составляла 2,58%, при 35 дБ — 0,84%, при 50 дБ — ноль. +# По передатчику: при -20 и -15 дБ отдельные кадры всё же не проходили +# контрольную сумму, при -10 дБ ошибок не было ни при одном из проверенных +# усилений приёмника (45, 50 и 55 дБ). Взят -10 дБ как единственный уровень +# с запасом. Расчётный предел для трассы 60 см составляет -4,5 дБ. +AIR_TX_GAIN_DB = -10.0 +AIR_RX_GAIN_DB = 50.0 +DEFAULT_ANTENNA_DISTANCE_CM = 60.0 RF_BANDWIDTH_HZ = 200_000 TX_GAIN_DB = -30.0 # Диапазон усиления приёмника получен опросом устройства 10 августа 2026 # года: hardwaregain_available = [-3 1 71] дБ в ручном режиме. RX_GAIN_DB = 20.0 -RX_BUFFER_SIZE = 262_144 +# Приёмный буфер подбирается под длину кадра: отдельные чтения не дают +# непрерывного потока отсчётов, и кадр, растянутый на несколько буферов, +# рвётся на стыках. Измерено: кадр 566 528 отсчётов при буфере 262 144 +# находился по преамбуле, но данные за ней приходили испорченными. +RX_BUFFER_LIMIT = 1 << 22 # Ослабление внешнего аттенюатора AT30S, включённого в разрыв тракта. ATTENUATOR_DB = 30.0 # PlutoSDR принимает целые отсчёты. Множитель приводит сигнал с амплитудой @@ -135,6 +175,7 @@ class FrameResult: sample_phase: int carrier_offset_hz: float carrier_coherence: float + cfo_correction_applied: bool error_vector_magnitude: float frame_found: bool header_parsed: bool @@ -147,6 +188,8 @@ class TransferMetrics: """Сводка одного прогона.""" loopback_mode: str + path_description: str + antenna_distance_cm: float source_image: str image_width: int image_height: int @@ -299,7 +342,46 @@ def software_channel(waveform: np.ndarray) -> np.ndarray: return waveform.copy() -def open_device(): +def maximum_air_tx_gain_db(antenna_distance_cm: float, carrier_hz: int) -> float: + """Рассчитать предельное усиление передатчика для эфирной петли. + + Аттенюатора в цепи нет, поэтому приёмник защищён только ослаблением на + трассе. Предел выводится из бюджета линии, а не задан константой: на + малом расстоянии он строже, на большом мягче. + """ + + if antenna_distance_cm <= 0.0: + raise ValueError("Расстояние между антеннами должно быть положительным") + + distance_km = antenna_distance_cm / 100.0 / 1000.0 + path_loss_db = 32.44 + 20.0 * math.log10(distance_km) + 20.0 * math.log10(carrier_hz / 1e6) + # В ближней зоне формула даёт отрицательные потери; принимаем ноль, + # то есть считаем, что вся мощность попадает в приёмник. + path_loss_db = max(path_loss_db, 0.0) + + allowed_input_dbm = RECEIVER_DAMAGE_THRESHOLD_DBM - RECEIVER_SAFETY_MARGIN_DB + return allowed_input_dbm + path_loss_db - PLUTO_MAXIMUM_OUTPUT_DBM - 2.0 * ASSUMED_ANTENNA_GAIN_DBI + + +def _receive_buffer_size(frame_samples: int) -> int: + """Подобрать размер приёмного буфера, вмещающий кадр целиком.""" + + needed = int(frame_samples * RECEIVE_MARGIN) + size = 1 << (needed - 1).bit_length() + if size > RX_BUFFER_LIMIT: + raise ValueError( + f"Кадру нужен буфер {size} отсчётов, что выше предела {RX_BUFFER_LIMIT}. " + f"Уменьшите размер фрагмента изображения" + ) + return size + + +def open_device( + carrier_hz: int = CABLE_CARRIER_HZ, + tx_gain_db: float = TX_GAIN_DB, + rx_buffer_size: int = 1 << 21, + rx_gain_db: float = RX_GAIN_DB, +): """Открыть PlutoSDR и настроить его для кабельной петли. Настройки выбраны так, чтобы приёмник не мог быть повреждён: с @@ -311,16 +393,16 @@ def open_device(): device = adi.Pluto(uri=PLUTO_URI) device.sample_rate = int(SAMPLE_RATE_HZ) - device.tx_lo = int(CARRIER_HZ) - device.rx_lo = int(CARRIER_HZ) + device.tx_lo = int(carrier_hz) + device.rx_lo = int(carrier_hz) device.tx_rf_bandwidth = int(RF_BANDWIDTH_HZ) device.rx_rf_bandwidth = int(RF_BANDWIDTH_HZ) - device.tx_hardwaregain_chan0 = float(TX_GAIN_DB) + device.tx_hardwaregain_chan0 = float(tx_gain_db) # Автоматическая регулировка меняла бы усиление по ходу приёма и # портила оценку качества сигнала, поэтому режим ручной. device.gain_control_mode_chan0 = "manual" - device.rx_hardwaregain_chan0 = float(RX_GAIN_DB) - device.rx_buffer_size = int(RX_BUFFER_SIZE) + device.rx_hardwaregain_chan0 = float(rx_gain_db) + device.rx_buffer_size = int(rx_buffer_size) # Циклический буфер повторял бы кадр бесконечно: передача не # заканчивалась бы, а приёмник видел тысячи копий одного кадра. device.tx_cyclic_buffer = False @@ -328,9 +410,13 @@ def open_device(): def hardware_transfer(device, frame_waveform: np.ndarray) -> np.ndarray: - """Передать один кадр и записать отрезок эфира, содержащий его.""" + """Передать один кадр и записать отрезок эфира, содержащий его. - needed_samples = int(len(frame_waveform) * RECEIVE_MARGIN) + Читается ровно один буфер. Склеивать несколько нельзя: между + отдельными чтениями поток отсчётов не является непрерывным, и кадр, + попавший на стык, разрушается. Поэтому буфер заранее выбран так, чтобы + вместить кадр целиком с запасом. + """ # Приём запускается до передачи. Первый буфер отбрасывается: он собран # до того, как кадр ушёл в эфир, и его назначение — привести приёмный @@ -340,15 +426,10 @@ def hardware_transfer(device, frame_waveform: np.ndarray) -> np.ndarray: device.tx_destroy_buffer() device.tx(to_device_samples(frame_waveform)) - chunks: list[np.ndarray] = [] - collected = 0 - while collected < needed_samples: - chunk = np.asarray(device.rx(), dtype=np.complex64) - chunks.append(chunk) - collected += len(chunk) + segment = np.asarray(device.rx(), dtype=np.complex64) device.tx_destroy_buffer() - return np.concatenate(chunks) / SAMPLE_SCALE + return segment / SAMPLE_SCALE # ==================================================================== приём @@ -371,16 +452,32 @@ def receive_frame(segment: np.ndarray, plan: FramePlan, taps: np.ndarray) -> tup return _failed_result(plan, correlation=float(found["score"]), phase=int(found["sample_phase"])), None estimate = radio.estimate_carrier_parameters(symbols[: radio.MARKER_BIT_COUNT], plan.marker) - corrected = radio.correct_phase_and_frequency( - symbols, - float(estimate["initial_phase_after_cfo"]), - float(estimate["phase_increment"]), - ) # Частоту берём готовой из оценщика, а не пересчитываем из приращения # фазы: два источника одной величины разошлись бы при правке любого из них. carrier_offset_hz = float(estimate["estimated_frequency_hz"]) carrier_coherence = float(estimate["cfo_coherence"]) + + # Решение о частотной коррекции принимается защитой из Lab023. При + # общем опорном генераторе истинный уход близок к нулю, а оценка по + # маркеру длиной 80 символов состоит почти целиком из шума. Коррекция + # таким значением поворачивает фазу вдоль всего кадра и портит его + # сильнее, чем отсутствие коррекции: измерено 65% ошибочных битов. + cfo_applied = radio.should_apply_cfo_correction( + carrier_offset_hz, + float(estimate["phase_consistency"]), + float(estimate["coherence_gain"]), + ) + + if cfo_applied: + corrected = radio.correct_phase_and_frequency( + symbols, + float(estimate["initial_phase_after_cfo"]), + float(estimate["phase_increment"]), + ) + else: + corrected = radio.correct_phase_and_frequency(symbols, float(estimate["constant_phase"]), 0.0) + error_vector = _error_vector_magnitude(corrected, plan.symbols) packet = radio.parse_radio_frame(radio.bpsk_demodulate(corrected)) @@ -403,6 +500,7 @@ def receive_frame(segment: np.ndarray, plan: FramePlan, taps: np.ndarray) -> tup sample_phase=int(found["sample_phase"]), carrier_offset_hz=carrier_offset_hz, carrier_coherence=carrier_coherence, + cfo_correction_applied=cfo_applied, error_vector_magnitude=error_vector, frame_found=True, header_parsed=header_parsed, @@ -412,10 +510,17 @@ def receive_frame(segment: np.ndarray, plan: FramePlan, taps: np.ndarray) -> tup return result, payload +def _mean_finite(values: list[float]) -> float: + """Среднее по величинам, которые удалось вычислить.""" + + finite = [value for value in values if np.isfinite(value)] + return float(np.mean(finite)) if finite else float("nan") + + def _failed_result(plan: FramePlan, correlation: float = 0.0, phase: int = 0) -> FrameResult: return FrameResult( plan.index, len(plan.packet), len(plan.bits), - correlation, phase, 0.0, 0.0, 0.0, False, False, False, False, + correlation, phase, 0.0, 0.0, False, float("nan"), False, False, False, False, ) @@ -426,22 +531,51 @@ def _error_vector_magnitude(received: np.ndarray, reference: np.ndarray) -> floa общего усиления тракта. """ + # Ноль здесь возвращать нельзя: он означал бы идеальный приём там, где + # величину вычислить не удалось. Такое значение уже один раз скрыло + # отказ приёмника, показав 0,000% при непрошедшей контрольной сумме. if not len(received) or len(received) != len(reference): - return 0.0 + return float("nan") scale = np.vdot(reference, received) / np.vdot(reference, reference) difference = received - scale * reference reference_power = float(np.mean(np.abs(reference) ** 2)) if reference_power <= 0.0: - return 0.0 + return float("nan") return float(np.sqrt(np.mean(np.abs(difference) ** 2) / reference_power)) # ==================================================================== прогон -def run_transfer(loopback_mode: str) -> tuple[TransferMetrics, tuple[FrameResult, ...], bytes | None, np.ndarray]: - """Выполнить полный перенос изображения выбранным каналом.""" +def run_transfer( + loopback_mode: str, + carrier_hz: int | None = None, + antenna_distance_cm: float = DEFAULT_ANTENNA_DISTANCE_CM, + tx_gain_db: float | None = None, +) -> tuple[TransferMetrics, tuple[FrameResult, ...], bytes | None, np.ndarray]: + """Выполнить полный перенос изображения выбранным каналом. + + Режимы: software замыкает канал в памяти, cable проходит через + аттенюатор по кабелю, air — через пару антенн по воздуху. + """ + + if tx_gain_db is None: + tx_gain_db = AIR_TX_GAIN_DB if loopback_mode == "air" else TX_GAIN_DB + + rx_gain_db = AIR_RX_GAIN_DB if loopback_mode == "air" else RX_GAIN_DB + + if loopback_mode == "air": + carrier_hz = carrier_hz if carrier_hz is not None else AIR_CARRIER_HZ + ceiling = maximum_air_tx_gain_db(antenna_distance_cm, carrier_hz) + if tx_gain_db > ceiling: + raise ValueError( + f"При расстоянии {antenna_distance_cm:.0f} см усиление передатчика ограничено " + f"{ceiling:.1f} дБ, запрошено {tx_gain_db:.1f} дБ. Аттенюатора в цепи нет, " + f"и приёмник защищён только ослаблением на трассе" + ) + else: + carrier_hz = carrier_hz if carrier_hz is not None else CABLE_CARRIER_HZ jpeg_bytes, applied_quality = prepare_jpeg() @@ -450,8 +584,16 @@ def run_transfer(loopback_mode: str) -> tuple[TransferMetrics, tuple[FrameResult waveforms = [shape_frame(plan.symbols, taps) for plan in plans] device = None - if loopback_mode == "hardware": - device = open_device() + if loopback_mode in ("cable", "air"): + buffer_size = _receive_buffer_size(max(len(item) for item in waveforms)) + device = open_device(carrier_hz, tx_gain_db, buffer_size, rx_gain_db) + + if device is not None: + # Прогрев тракта перед началом опыта, как и первый отбрасываемый + # приёмный буфер. Проверка показала, что отказы кадров случайны и + # к первой передаче не привязаны, поэтому прогрев остаётся мерой + # предосторожности, а не исправлением известного дефекта. + hardware_transfer(device, waveforms[0]) started = time.monotonic() results: list[FrameResult] = [] @@ -488,8 +630,16 @@ def run_transfer(loopback_mode: str) -> tuple[TransferMetrics, tuple[FrameResult recovered = sum(1 for item in results if item.fragment_recovered) successful = [item for item in results if item.frame_found] + path_description = { + "software": "канал замкнут в памяти, оборудование не использовалось", + "cable": f"кабель через аттенюатор {ATTENUATOR_DB:.0f} дБ", + "air": f"две антенны по воздуху, расстояние {antenna_distance_cm:.0f} см, аттенюатора нет", + }[loopback_mode] + metrics = TransferMetrics( loopback_mode=loopback_mode, + path_description=path_description, + antenna_distance_cm=antenna_distance_cm if loopback_mode == "air" else 0.0, source_image=SOURCE_IMAGE.as_posix(), image_width=IMAGE_WIDTH, image_height=IMAGE_HEIGHT, @@ -504,7 +654,7 @@ def run_transfer(loopback_mode: str) -> tuple[TransferMetrics, tuple[FrameResult symbol_rate=SYMBOL_RATE, samples_per_symbol=SAMPLES_PER_SYMBOL, sample_rate_hz=SAMPLE_RATE_HZ, - carrier_hz=CARRIER_HZ, + carrier_hz=carrier_hz, transmitted_samples=transmitted_samples, air_time_seconds=air_time, frames_received=len(successful), @@ -523,10 +673,10 @@ def run_transfer(loopback_mode: str) -> tuple[TransferMetrics, tuple[FrameResult wall_clock_bitrate_kbps=len(jpeg_bytes) * 8.0 / transfer_seconds / 1000.0 if transfer_seconds > 0 else 0.0, mean_correlation_score=float(np.mean([item.correlation_score for item in successful])) if successful else 0.0, mean_carrier_offset_hz=float(np.mean([item.carrier_offset_hz for item in successful])) if successful else 0.0, - mean_error_vector_magnitude=float(np.mean([item.error_vector_magnitude for item in successful])) if successful else 0.0, - tx_gain_db=TX_GAIN_DB if loopback_mode == "hardware" else 0.0, - rx_gain_db=RX_GAIN_DB if loopback_mode == "hardware" else 0.0, - attenuator_db=ATTENUATOR_DB if loopback_mode == "hardware" else 0.0, + mean_error_vector_magnitude=_mean_finite([item.error_vector_magnitude for item in successful]), + tx_gain_db=tx_gain_db if loopback_mode != "software" else 0.0, + rx_gain_db=rx_gain_db if loopback_mode != "software" else 0.0, + attenuator_db=ATTENUATOR_DB if loopback_mode == "cable" else 0.0, ) return metrics, tuple(results), received_image, last_segment @@ -632,6 +782,18 @@ def run_functional_tests() -> tuple[FunctionalTestResult, ...]: f"внутри допустимых пределов, сигнал занимает {signal_bandwidth:.0f} Гц" ) + def air_gain_ceiling_is_enforced() -> str: + try: + ceiling = maximum_air_tx_gain_db(DEFAULT_ANTENNA_DISTANCE_CM, AIR_CARRIER_HZ) + run_transfer("air", tx_gain_db=ceiling + 1.0) + raise AssertionError("превышение потолка усиления в эфирном режиме принято") + except ValueError: + pass + near = maximum_air_tx_gain_db(5.0, AIR_CARRIER_HZ) + far = maximum_air_tx_gain_db(200.0, AIR_CARRIER_HZ) + assert near < far, "на малом расстоянии предел обязан быть строже" + return f"предел зависит от расстояния: {near:.1f} дБ на 5 см против {far:.1f} дБ на 200 см" + checks = ( ("01. Размер подготовленного JPEG", jpeg_fits_the_limit), ("02. Разбиение и сборка изображения", fragments_roundtrip), @@ -643,6 +805,7 @@ def run_functional_tests() -> tuple[FunctionalTestResult, ...]: ("08. Потеря фрагмента", lost_fragment_does_not_spoil_the_rest), ("09. Достижимость частоты дискретизации", sample_rate_is_reachable), ("10. Настройки устройства в пределах", device_settings_are_within_limits), + ("11. Потолок усиления в эфирном режиме", air_gain_ceiling_is_enforced), ) for name, function in checks: @@ -737,15 +900,26 @@ def write_report( f"- Канал: {metrics.loopback_mode}.", ] - if metrics.loopback_mode == "hardware": + lines.append(f"- Трасса: {metrics.path_description}.") + + if metrics.loopback_mode == "cable": lines.extend( [ - f"- Схема: выход передатчика, аттенюатор {metrics.attenuator_db:.0f} дБ, вход приёмника.", - f"- Несущая {metrics.carrier_hz / 1e6:.3f} МГц, одинаковая на передачу и приём.", + f"- Несущая {metrics.carrier_hz / 1e6:.3f} МГц, одинаковая на передачу и приём. Сигнал в эфир не выходит, поэтому её значение произвольно.", f"- Усиление передатчика {metrics.tx_gain_db:.1f} дБ, приёмника {metrics.rx_gain_db:.1f} дБ, режим ручной.", "- Антенны не подключались: они создали бы путь распространения параллельно кабельному.", ] ) + elif metrics.loopback_mode == "air": + lines.extend( + [ + f"- Несущая {metrics.carrier_hz / 1e6:.3f} МГц — внутри целевого диапазона 200-250 МГц, закреплённого Записью 018.", + f"- Усиление передатчика {metrics.tx_gain_db:.1f} дБ, приёмника {metrics.rx_gain_db:.1f} дБ, режим ручной.", + f"- Антенны разнесены на {metrics.antenna_distance_cm:.0f} см. Аттенюатора в цепи нет: приёмник защищён ослаблением на трассе.", + "- Использованы штыри, рассчитанные на 108 МГц. На рабочей частоте они работают как полуволновые, согласование плохое, но для этой дистанции достаточное.", + "- Уровни в эфирной трассе не контролируются так же строго, как в кабельной: возможны переотражения от предметов в помещении. Прогон служит проверкой тракта устройства, а не измерением.", + ] + ) else: lines.append("- Оборудование не использовалось: канал замкнут в памяти. Прогон служит эталоном.") @@ -827,7 +1001,7 @@ def write_report( def validate_outputs(metrics: TransferMetrics, results: tuple[FrameResult, ...], tests: tuple[FunctionalTestResult, ...]) -> None: """Проверить состав и связность полученных файлов.""" - assert len(tests) == 10 and all(item.passed for item in tests) + assert len(tests) == 11 and all(item.passed for item in tests) assert len(results) == metrics.fragment_count for path in (SUMMARY_CSV, FRAMES_CSV): @@ -855,16 +1029,45 @@ def main() -> None: parser = argparse.ArgumentParser(description="Lab042: передача JPEG через кабельную петлю") parser.add_argument( "--loopback", - choices=("software", "hardware"), + choices=("software", "cable", "air"), default="software", - help="software замыкает канал в памяти, hardware использует PlutoSDR", + help="software замыкает канал в памяти, cable — кабель через аттенюатор, air — две антенны по воздуху", + ) + parser.add_argument( + "--carrier-mhz", + type=float, + default=None, + help="несущая, МГц. По умолчанию 435 для кабеля и 225 для эфира", + ) + parser.add_argument( + "--antenna-distance-cm", + type=float, + default=DEFAULT_ANTENNA_DISTANCE_CM, + help="расстояние между антеннами в эфирном режиме, см", + ) + parser.add_argument( + "--tx-gain-db", + type=float, + default=None, + help=f"усиление передатчика, дБ. По умолчанию {TX_GAIN_DB:.0f} для кабеля и {AIR_TX_GAIN_DB:.0f} для эфира. В эфире предел считается по расстоянию", ) arguments = parser.parse_args() OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) + # Файлы прошлого прогона удаляются: иначе принятое изображение от + # удачного запуска осталось бы лежать рядом с результатами неудачного. + for stale in OUTPUT_DIRECTORY.glob("*"): + if stale.is_file(): + stale.unlink() tests = run_functional_tests() - metrics, results, received_image, segment = run_transfer(arguments.loopback) + carrier_hz = int(arguments.carrier_mhz * 1e6) if arguments.carrier_mhz else None + metrics, results, received_image, segment = run_transfer( + arguments.loopback, + carrier_hz=carrier_hz, + antenna_distance_cm=arguments.antenna_distance_cm, + tx_gain_db=arguments.tx_gain_db, + ) if received_image is not None: RECEIVED_IMAGE_PATH.write_bytes(received_image) diff --git a/protocol/bpsk_radio.py b/protocol/bpsk_radio.py index 064995a..9f517c2 100644 --- a/protocol/bpsk_radio.py +++ b/protocol/bpsk_radio.py @@ -31,6 +31,13 @@ SYMBOL_RATE = 20_000 CFO_REFINEMENT_HALF_WIDTH_HZ = 200.0 CFO_REFINEMENT_STEP_HZ = 1.0 +# Пороги разрешения частотной коррекции. Значения из Lab023. Смысл в том, +# что при малом истинном уходе оценка по короткому маркеру состоит почти +# целиком из шума, и коррекция таким значением портит длинный кадр сильнее, +# чем отсутствие коррекции вообще. +CFO_DEAD_ZONE_HZ = 15.0 +MINIMUM_PHASE_CONSISTENCY = 0.55 + MAXIMUM_PROTOCOL_PACKET_BYTES = 4096 # Перенесено дословно из 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 + 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 + )