Lab049: define ELRS control and SDR video architecture

This commit is contained in:
LittleSam129
2026-09-08 13:26:06 +03:00
parent ac6cc792b8
commit cdf5396e80
12 changed files with 3139 additions and 0 deletions

View File

@@ -1367,3 +1367,277 @@ passed и 0 failed; `git diff --check` прошёл.
STOP. Не переходить к Lab049, видео, QPSK, повышению символьной скорости или
новому RF-сеансу без отдельной постановки. `RF TX = 0`; HackRF не запускать.
# Запись 036
## Дата
3 сентября 2026 года
## Тема
Lab049: программное повышение символьной скорости BPSK с сохранением
приоритета управления.
## Выполнено
До изменения кода проверены Lab047--Lab048, зависимости PHY и решения
Lab033--Lab041. Подтверждено, что новый планировщик не нужен:
`protocol/priority_scheduler.py` уже задаёт непрерываемое обслуживание с
порядком `EMERGENCY > CONTROL > TELEMETRY > VIDEO`, а `LATEST_STATE` заменяет
устаревшие ожидающие состояния CONTROL и TELEMETRY. Политики Lab034--Lab036
сохраняют целостность видеокадров и допускают служебный трафик между пакетами.
Реализован отдельный программный параметрический адаптер Lab049, не меняющий
доказанный аппаратный тракт Lab047. При `Fs = 8 Мвыб/с` проверены 20, 50, 100
и дополнительный режим 200 ксимв/с, то есть ровно 400, 160, 80 и 40
выборок/символ. Режим 20 ксимв/с побайтно воспроизводит прежнюю CS8-форму
Lab045. BPSK, PRBS7, PHY-заголовок, свёрточный FEC `R=1/2, K=7, 171/133
octal`, перемежитель, CRC, packet format и fragment format не изменялись.
Во всех режимах использован сохранённый JPEG Lab047: 8724 байта, RGB
320 x 240, SHA-256
`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`,
36 физических кадров. Каждый режим прошёл полный frame-aligned IQ-тракт от
фрагментации и BPSK/RRC до независимого восстановления PHY-длины, FEC, CRC и
сборки объекта. Приёмник кадра не получает TX bits, payload, packet или
ожидаемое число кадров; диагностическое сравнение выполняется после CRC.
До полного прогона для всех скоростей одинаково зафиксирован шум
`coded-bit Eb/N0 = 7,0 dB`, семена `49000 + frame_index`. Sigma вычисляется
из фактической энергии кодированного BPSK-бита, поэтому корректно меняется с
samples/symbol и не подбирается по результату режима.
Идеальный и шумный каналы восстановили 36/36 кадров и точный JPEG во всех
четырёх режимах. В шумном канале получено 129/114/107/110 ошибок кодированных
бит до FEC для 20/50/100/200 ксимв/с; после FEC во всех режимах 0 ошибок,
CRC PASS/FAIL `36/0`, ошибочных байтов и битов JPEG `0/0`.
Длительность одного физического кадра равна 288,30/115,32/57,66/28,83 мс,
полного JPEG — 10,37880/4,15152/2,07576/1,03788 с. Полезная скорость объекта
равна 6,724/16,811/33,622/67,245 кбит/с: фактический выигрыш 1,0/2,5/5,0/10,0
раза, рассчитанный по неизменному кадру.
Шумный приём занял 7,14440/3,15468/1,92894/1,30565 с; отношение обработки к
длительности сигнала 0,68836/0,75989/0,92927/1,25799. Режимы 20, 50 и 100
ксимв/с успевают в реальном времени, 200 ксимв/с — нет. Поэтому 200 ксимв/с
является устойчиво декодируемым, но только частичным результатом.
Проверка существующего `LATEST_STATE` на постоянно заполненной VIDEO-очереди,
CONTROL каждые 50 мс, TELEMETRY каждые 100 мс и одной EMERGENCY подтвердила
порядок `VIDEO(active) -> EMERGENCY -> CONTROL -> TELEMETRY -> VIDEO`.
Начатый кадр не прерывается. Верхняя граница блокировки новой CONTROL или
EMERGENCY равна длительности кадра: 288,30/115,32/57,66/28,83 мс.
Для нагрузки планировщика каждое сообщение занимает целый неизменный
физический кадр Lab045. Прикладные CONTROL и EMERGENCY не дополняются внутри
`LinkPacket`: сохранены точные 36- и 34-байтные сообщения, проверено их
обратное декодирование. При CONTROL 20 сообщений/с и TELEMETRY 10 сообщений/с
требуется 30 физических кадров/с. Ёмкость режимов равна
3,469/8,672/17,343/34,686 кадра/с, поэтому остаток полезной скорости VIDEO
равен 0/0/0/9,085 кбит/с, или 0/0/0/0,1302 JPEG в секунду. Это программная
модель планирования, а не реализованная упаковка `LinkPacket` в физический
кадр.
Штатный `LATEST_STATE` заменяет ожидающее старое состояние, но сам по себе не
удаляет пакет по истечении `deadline_ms`. В нагрузочном сценарии при 50
ксимв/с один CONTROL начал передаваться через 100,760 мс при сроке 100 мс;
при 20 ксимв/с EMERGENCY ожидала 87,900 мс при сроке 50 мс. Конкретное
событие при 100 ксимв/с ожидало 30,240 мс, но худшая фаза 57,660 мс всё равно
нарушает 50-миллисекундный срок EMERGENCY.
Статусы всех режимов — `PARTIAL`. 20 и 50 ксимв/с нарушают требования по
блокировке и не оставляют VIDEO; 100 ксимв/с укладывает CONTROL в 100 мс, но
нарушает срок EMERGENCY и также не оставляет VIDEO; 200 ксимв/с исправляет
эти ограничения, но приёмник не успевает в реальном времени с отношением
1,25799. Для следующего отдельного аппаратного опыта только с PHY выбран
диагностический кандидат 100 ксимв/с и 80 выборок/символ. Для объединённой
работы он не рекомендован: `recommended_for_integrated_operation = false`,
полностью пригодного режима не найдено.
Созданы `experiments/lab049_bpsk_symbol_rate.py`,
`tests/test_lab049_bpsk_symbol_rate.py`, `docs/lab049_current_state.md` и
`data/processed/lab049/result.json`. Новый RF не выполнялся, `RF TX = 0`,
HackRF не запускался.
Целевые проверки Lab049 дали 17 passed. `python tools/quick_gate.py` прошёл:
564 теста ядра и 12 функциональных проверок. `python -m pytest tests/ -q` дал
564 passed и 0 failed; единственное предупреждение относится к запрету записи
в служебный `.pytest_cache`. `git diff --check` прошёл.
## Следующий этап
STOP. До объединённого режима отдельно решить просроченные команды, упаковку
служебных сообщений в физический кадр и перегрузку бюджета. Не выполнять
аппаратный Lab049, QPSK, реальное видео, новый RF или переход на 180 МГц без
отдельной постановки и явной авторизации.
# Запись 037
## Дата
8 сентября 2026 года.
## Тема
Lab049: независимый IQ-приём и исправление расчёта полной доставки команд.
## Выполнено
Проверен HEAD ac6cc792b896e34f7dd6d291955a0387fdafcf41 и обнаружен уже
существующий незакоммиченный вариант Lab049. Запись 036 сохранена.
Независимые verifier/adversary воспроизвели отказ исходного приёмника при
неизвестном начале +240 выборок и CFO +10 Гц на 100 ксимв/с, а также ошибочный
расчёт аварийного срока по началу вместо конца передачи.
Основной IQ-проход использует блочный поиск и строгий декодер Lab048,
lab046c_offline_recovery.stream_process_capture. Адаптер читает синтетический
complex64 без отсечения шума, временно задаёт Rs/SPS и возвращает константы
и функции через finally. TX offsets, объект и число кадров в RX не передаются.
Порог Lab047 0.030964473262429237, PRBS7, BPSK, PHY, FEC, CRC и форматы сохранены.
Дробные скорости и нецелое samples/symbol отвергаются без округления.
При Fs=8 Мвыб/с проверен тот же JPEG 8724 байта, RGB 320×240,
SHA e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a,
в режимах 20/50/100/200 ксимв/с. Идеальный канал восстановил 36/36 и точный JPEG
во всех режимах. Шум един: coded-bit Eb/N0=7 dB, фиксированные seeds,
остаточный CFO +10 Гц, фаза 0,73 рад и чередование полярности.
В шуме 20 ксимв/с восстановил только 6/36 кадров, JPEG не собран — FAIL.
50/100/200 восстановили 36/36, CRC PASS/FAIL 36/0, SHA match, ошибки JPEG 0/0.
До FEC на принятых кадрах 67/147/119/125 ошибок, после FEC — 0; первая пара
относится только к шести кадрам. Все восемь проходов дошли до конца IQ.
Полная обработка шумного IQ заняла 173,65057 / 5,72980 / 7,76244 / 8,17573 с,
при длительности IQ 10,458018 / 4,230738 / 2,154978 / 1,117098 с.
Отношения 16,60454 / 1,35433 / 3,60210 / 7,31872: ни один режим не успевает
в реальном времени. Меньшие времена записи 036 описывали заранее выровненные
кадры и не переносятся на полный независимый тракт. 50/100/200 — PARTIAL.
Переиспользован LATEST_STATE и сохранены форматы CONTROL/EMERGENCY.
Отдельно считаются ожидание начала, полная доставка, активное окно 2 с
и последующее опустошение очереди. На 100 ксимв/с максимум доставки CONTROL
105,64 мс и EMERGENCY 87,90 мс; превышений сроков соответственно 6 и 1.
На 20/50/100 внутри активного окна не доставлен ни один TELEMETRY.
На 200 худшая доставка EMERGENCY равна почти 57,66 мс, а не 28,83 мс:
к блокировке добавляется передача самой команды. Срок 50 мс нарушается.
В модели одного полного PHY-кадра на пакет CONTROL 20 Гц и TELEMETRY 10 Гц
перегружают 20/50/100 ксимв/с. На 200 остаются 9,085 кбит/с VIDEO, около
0,1302 JPEG/с; с одной EMERGENCY за 2 с — 8,116 кбит/с. Аппаратная упаковка
служебных LinkPacket в PHY не реализована и этой моделью не доказывается.
Для отдельного будущего диагностического PHY-опыта выбран 50 ксимв/с:
наименьшая вычислительная перегрузка среди точных шумных проходов. Это не
пригодный объединённый канал и не разрешение RF. Старый JSON сохранён как
data/processed/lab049/frame_aligned_result.json; актуальный result.json
и docs/lab049_current_state.md содержат отрицательные результаты.
Добавлены docs/lab049_audit.md, experiments/lab049_independent_iq.py и
семь регрессий tests/test_lab049_independent_iq.py. Старые тесты не изменялись.
Обновлён docs/LABS_INDEX.md. Модули protocol не изменялись.
Verifier: 24 целевых теста PASS, независимый пересчёт JSON совпал.
Adversary подтвердил корректный отрицательный аварийный вердикт.
quick_gate PASS: 571 тест ядра, 12 функциональных проверок.
pytest: 571 passed, 0 failed; одно предупреждение служебного .pytest_cache.
git diff --check PASS. RF TX=0. Git add/commit/push/pull/fetch не выполнялись.
Следующий этап: STOP; отдельная постановка для вычислительной перегрузки,
бюджета и задержек перед аппаратной проверкой. Не запускать RF, QPSK,
реальный видеопоток, новый FEC или 180 МГц в рамках этой работы.
# Запись 038
## Дата
8 сентября 2026 года.
## Тема
Lab049: архитектура CRSF/ExpressLRS и бюджет отдельного SDR-видеоканала.
## Выполнено
Повторно проверены main и HEAD ac6cc792b896e34f7dd6d291955a0387fdafcf41.
Существовавшие незакоммиченные Lab049, тесты, данные и записи 036–037 сохранены.
Аудит README, журнала, индекса, Lab033–041, Lab047–048 и protocol подтвердил:
общий EMERGENCY > CONTROL > TELEMETRY > VIDEO создавался для собственного
единого ресурса. Его CONTROL 20 Гц и TELEMETRY 10 Гц в полных видеокадрах
остаются историческим стресс-тестом, не целевой моделью CRSF/ELRS.
По официальному ExpressLRS 4.1.0, коммиту
a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6 и спецификации TBS проверены
CRSF framing, UART, OTA, интервалы, Full/повторные режимы и телеметрия.
CRSF RC: 26 байт по проводу; ELRS OTA4/OTA8: 8/13 байт, DATA: 5/10 байт.
Ссылки на источники и точные допущения размещены в
docs/lab049_crsf_elrs_architecture.md и crsf_elrs_budget.json.
Рекомендуемая архитектура: обычный RadioMaster, штатные ELRS TX/RX,
CRSF к контроллеру ровера для управления и критической телеметрии;
отдельный SDR для изображений и объёмных данных. Будущие примерно 180 МГц
относятся к SDR, не к стандартному ELRS. Лабораторная RF-база — 1400 МГц.
Модели аппаратуры и ELRS-диапазон не уточнены; 100 Full/8ch/1:8 — условный
кандидат сравнительной модели, не назначенная аппаратная конфигурация.
При 100 Full/1:8 расчёт даёт до 87,5 RC-возможностей/с, 12,5 TLM-слотов/с,
среднее ожидание RC 6,25 мс и верхнюю границу 20 мс только для идеального
расписания RC/TLM. Полная задержка стики→ровер не измерена. DATA после
LINK имеет потолок 116,667 байт/с до прочих расходов. Даже наиболее щедрый
предел 10000 байт/с для двух DATA-трактов ELRS ниже JPEG 3 кадра/с:
26172 байт/с. Обычная ELRS-телеметрия для видео недостаточна.
Свойства безопасности Lab038–041 сохранены. При потере ELRS живое видео
не разрешает движение. Требуются локальный таймер, аварийная защёлка,
безопасная загрузка и отдельное повторное разрешение движения.
Сопряжение не реализовано: RC CRSF не содержит session/boot/epoch/sequence
нашего протокола. Локальная нумерация UART не доказывает свежесть источника.
Порог ELRS TX для старого ввода пульта 1 с явно отмечен как незакрытое
ограничение относительно модельных таймеров 100/200 и 150/250 мс.
JPEG Lab047 сохранён: 8724 байта, RGB 320×240, SHA
e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a.
Фактические планы Lab046/Lab045 дают 36 кадров, 206064 BPSK-бита и
207576 символьных интервалов с защитными интервалами/фильтром.
Для 3/5/10 кадров/с JPEG payload требует 209,376/348,960/697,920 кбит/с,
нынешняя PHY — минимум 622,728/1037,880/2075,760 ксимв/с без запаса.
Управление ELRS из этого SDR-бюджета не вычитается.
20/50/100/200 ксимв/с дают в идеале 0,09635/0,24088/0,48175/0,96350 JPEG/с.
Существующие независимые шумные замеры переиспользованы по SHA, не повторены:
на 50/100/200 точный JPEG, но обработка занимает 5,72980/7,76244/8,17573 с,
отношение к IQ 1,35433/3,60210/7,31872. На 20 только 6/36 кадров.
Lab048 15 с → 13,966058 с остаётся фактом одной записи, не доказательством
реального времени при повышении скорости PHY.
Конечная цель — больше 3 кадров/с; 3 промежуточно, далее 5 и 10.
При 25 км/ч между изображениями соответственно 2,315/1,389/0,694 м.
Рассчитана полная таблица для 10/15/25 км/ч и 1/3/5/10 кадров/с.
Предел задержки изображения в один период (333,33/200/100 мс) обозначен
как инженерное предложение, не измеренный или утверждённый норматив.
Камера, живое кодирование, очередь и экран пока не измерены; RX включает
JPEG decode, поэтому оно не прибавляется повторно. Кодек не менялся.
Добавлены experiments/lab049_crsf_elrs_budget.py, 35 тестов в
tests/test_lab049_crsf_elrs_budget.py и отдельный машинный результат
data/processed/lab049/crsf_elrs_budget.json. Обновлены документы состояния,
индекс и краткий указатель актуальной архитектуры в README.
Старые protocol tests и модули protocol не изменены.
Verifier независимо пересчитал расходы PHY, RC-ожидание перечислением
интервалов, LINK/DATA и SHA исходных замеров; 35 целевых тестов PASS.
Adversary: 7 проверок сеансов PASS; при живом видео без команд watchdog
переходит в ступени 100/200 мс. Искусственная нумерация старого состояния
подтверждает описанное ограничение свежести, не готовность bridge.
Воспроизведённых расхождений в новой работе не обнаружено.
quick_gate PASS: 606 тестов и 13 функциональных проверок.
pytest: 606 passed за 64,32 с; одно предупреждение о доступе к .pytest_cache.
git diff --check PASS. Дополнительный ruff не выполнен: модуль не установлен.
RF TX = 0; оборудование не запускалось и не инвентаризировалось.
Git add/commit/push/pull/fetch не выполнялись.
Следующий этап: STOP. Рекомендуемый Lab050 — отдельная программная задача
профилирования/потоковой обработки SDR-видео с ограничением возраста очереди
и проверкой выполнимости 5 кадров/с. Сопряжение CRSF и его сценарии отказов
требуют отдельной постановки и уточнения аппаратуры. Нового RF, QPSK,
CRSF/ELRS-протокола, перекодирования JPEG или переноса на 180 МГц здесь нет.

View File

@@ -1,5 +1,13 @@
# SDR Rover Link
Актуальное уточнение архитектуры, Lab049 от 8 сентября 2026 года:
управление и критическая телеметрия — стандартные RadioMaster/ExpressLRS и CRSF;
изображения и объёмные данные — отдельный SDR, в перспективе около 180 МГц.
Цель изображения — больше 3 кадров/с, следующий ориентир 5, дальнейший 10.
См. [аудит и бюджет Lab049](docs/lab049_crsf_elrs_architecture.md).
Основной текст ниже описывает более раннюю контрольную точку; текущие результаты
Lab047–049 перечислены в [индексе лабораторий](docs/LABS_INDEX.md).
## 1. Назначение проекта
**SDR Rover Link** — учебно-исследовательский проект по разработке цифрового радиоканала для наземного робототехнического комплекса с использованием программно-определяемого радио — SDR, Software Defined Radio.

View File

@@ -200,6 +200,46 @@
- **RF:** новый радиосеанс не выполнялся, `RF TX = 0`.
- **Журнал:** запись 035 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)).
## Lab049
- **Актуальная архитектура:** RadioMaster + штатные ELRS TX/RX, CRSF к контроллеру
ровера; независимый SDR для видео и объёмных данных. Будущий SDR около 180 МГц,
лабораторная база 1400 МГц. Это не перенос штатного ELRS на 180 МГц.
- **Аудит CRSF/ELRS:** официальный релиз 4.1.0, размеры OTA 8/13 байт,
реальные интервалы и доли телеметрии; ELRS недостаточно для JPEG >3 кадра/с.
- **Бюджет SDR:** RC/критическая телеметрия в отдельном ELRS не вычитаются.
20/50/100/200 ксимв/с дают в идеале 0,09635 / 0,24088 / 0,48175 / 0,96350 JPEG/с.
Для 3/5/10 требуются минимум 622,728 / 1037,880 / 2075,760 ксимв/с
по фактическому текущему PHY. 3 — промежуточный уровень; цель >3, ориентиры 5 и 10.
- **Задача предыдущего опыта:** 20/50/100/200 ксимв/с при Fs=8 Мвыб/с,
полный независимый IQ-приём и исторический стресс-тест общего канала.
- **Актуальный результат:** идеал — 36/36 во всех режимах. Шум Eb/N0=7 dB, CFO +10 Гц:
20 — 6/36, FAIL; 50/100/200 — точный JPEG 36/36, CRC 36/0, PARTIAL.
- **Полный приёмник Lab048:** processing/IQ 16,60454 / 1,35433 / 3,60210 / 7,31872;
ни один режим не успевает в реальном времени в шумном сценарии.
- **Исторические задержки общего канала:** блокировка 288,30 / 115,32 / 57,66 / 28,83 мс. Доставка EMERGENCY
в худшей фазе включает ещё один кадр; даже на 200 она требует почти 57,66 мс.
- **Исторический стресс-тест:** CONTROL 20 Гц и TELEMETRY 10 Гц в 30 полных
кадрах/с оставляли VIDEO только на 200. Это не целевая CRSF/ELRS-архитектура.
- **Диагностический PHY-кандидат:** 50 ксимв/с, 160 выборок/символ —
наименьшая вычислительная перегрузка среди точных шумных проходов. RF не разрешён.
- **Код:** [расчётчик ELRS/SDR](../experiments/lab049_crsf_elrs_budget.py),
[Lab049 IQ](../experiments/lab049_bpsk_symbol_rate.py),
[независимый IQ-адаптер](../experiments/lab049_independent_iq.py).
- **Тесты:** [35 расчётных](../tests/test_lab049_crsf_elrs_budget.py),
[исходные](../tests/test_lab049_bpsk_symbol_rate.py),
[регрессии IQ](../tests/test_lab049_independent_iq.py).
- **Отчёт:** [архитектура CRSF/ELRS](lab049_crsf_elrs_architecture.md),
[новый бюджет JSON](../data/processed/lab049/crsf_elrs_budget.json),
[состояние](lab049_current_state.md), [аудит](lab049_audit.md),
[JSON](../data/processed/lab049/result.json).
- **История:** [старый frame-aligned результат](../data/processed/lab049/frame_aligned_result.json)
не измерял полный независимый поиск. Журнал: записи 036–038.
- **Безопасность:** свойства Lab038–041 сохраняются; сопряжение CRSF ещё не проверено.
Видео не разрешает движение при потере ELRS. Модули protocol и прежние тесты сохранены.
- **Далее:** программный Lab050 — профиль RX, поток видео и ограничение возраста очереди.
- **RF:** RF TX = 0, STOP.
## Правило хранения данных
Большие записи IQ, потоки `cs8`, бинарные объекты и другие аппаратные исходные

86
docs/lab049_audit.md Normal file
View File

@@ -0,0 +1,86 @@
# Lab049: аудит исходного незакоммиченного варианта
8 сентября 2026 года. Корень `C:/Users/user/Desktop/projects/SDR_Rover`,
ветка `main`, HEAD `ac6cc792b896e34f7dd6d291955a0387fdafcf41`.
До работы уже существовали незакоммиченные реализация Lab049, тесты,
`data/processed/lab049/result.json`, документ состояния и запись 036 журнала.
Прежний JSON сохранён; он относится к выровненным кадрам и не является
результатом полного независимого приёмника.
## Карта архитектуры
1. Базовый Rs задан в `experiments/lab043_hackrf_bpsk.py`: 20 000 символов/с.
Lab044 и Lab045 наследуют его, Lab047 явно проверяет аппаратную точку.
2. В Lab043 `SAMPLES_PER_SYMBOL = SAMPLE_RATE_HZ // SYMBOL_RATE`; Lab047
отдельно фиксирует 400. Lab049 теперь отвергает дробные частоты и
нецелое отношение, не округляя вход.
3. Жёсткие зависимости Lab043: `_refine_frame_start`, `_matched_filter_taps`,
`_matched_waveform`, `_preamble_timing_offset`, `_estimate_cfo_gain`,
`_evaluate_symbols`. Lab044: `_scale_and_carrier`,
`_preamble_reference_samples`. Lab045: длительности TransmissionPlan;
Lab046B: `_extract_soft_window` и `_decode_iq_candidate`. Временные шкалы
используют Rs, фильтры и индексы — samples/symbol. В локальном поиске
также есть фиксированные пределы ±800 и ±2000 выборок; они сохранены.
4. `shape_frame_like_lab042`, RRC-фильтр ядра, преобразования бит/BPSK,
фрагментация, packet/PHY header, FEC, перемежитель, CRC и сборка объекта
уже параметрические либо вообще не зависят от Rs.
5. `protocol/priority_scheduler.py`: строгий приоритет четырёх классов,
непрерываемая передача пакета и `LATEST_STATE` для ожидающих состояний.
Lab034–Lab036 добавили удаление устаревших изображений и изоляцию
видеокадров в собственных блоках FEC; это внешнее стирающее кодирование
видео, не новый свёрточный FEC Lab045.
6. Минимальная доработка: адаптер скорости и чтения синтетического IQ к
`lab046c_offline_recovery.stream_process_capture`, который используется
Lab048; разделение ожидания/доставки и активного окна/опустошения очереди.
Новый планировщик и изменения `protocol/` не потребовались.
## Проверка Lab033–Lab041
Сверены сохранённые отчёты этих лабораторий и связанные модули.
Lab033 подтверждает строгий приоритет и отдельно считает ожидание начала
и завершение передачи. Lab034: frame-aligned FEC изолирует удаляемое
изображение, начатый пакет не прерывается. Lab035–Lab036 ограничивают возраст
изображения и число ожидающих кадров. Lab037 подтверждает вставку управления
между пакетами VIDEO. Lab038–Lab040 добавляют failsafe, аварийную защёлку,
повторы и ACK; Lab041 — сеансы/перезапуски. Эти механизмы безопасности
в Lab049 не заменены и не заявлены как аппаратно проверенные.
CONTROL взят из последнего Lab041: `ControlStateMessage`, 36 байт, 20 Гц;
с 32-байтным LinkPacket — 68 байт и 10 880 бит/с до физического кодирования.
TELEMETRY наследуется из Lab037 через Lab038–Lab041: синтетические 64 байта,
10 Гц, с LinkPacket — 96 байт и 7680 бит/с. Отдельного формата физических
измерений телеметрии эти лаборатории не задают.
## Воспроизведённые несоответствия
Проверки выполнены независимыми `verifier` и `adversary`, без правок с их
стороны и без оборудования.
- Исходный `receive_iq_frame`: идеальный кадр 100 ксимв/с проходит CRC,
добавление 240 нулевых выборок перед ним даёт `PHY=False, CRC=NOT_REACHED`.
Поиск ограничивался двумя символами от известной TX-границы.
- Добавление +10 Гц к тому же кадру даёт `PHY=True, CRC=NOT_REACHED`;
+100 Гц — `PHY=False`. Остаточная частота не оценивалась.
- При 100 ксимв/с старый отчёт CONTROL max 47,980 мс описывал начало,
а получение пакета занимает 105,640 мс; пропущены 6 сроков CONTROL.
EMERGENCY: 30,240 мс до начала, 87,900 мс до получения — пропущен срок 50 мс.
- При 200 ксимв/с EMERGENCY через 1 мкс после VIDEO доставляется через
57,659 мс, а не за 50 мс. Штатный failsafe до получения пакета остаётся
в `normal`. Блокировка 28,83 мс и доставка почти 57,66 мс — разные метрики.
- На 20/50/100 ксимв/с в двухсекундном окне не доставлено ни одного
TELEMETRY. Единственное сообщение старой таблицы доставляется при
последующем опустошении очереди.
Регрессии воспроизводятся командой:
```powershell
python -m pytest tests/test_lab049_independent_iq.py -q
```
Исторический `receive_iq_frame` сохранён для локальных регрессий формы;
основной проход Lab049 его больше не использует. Старые тесты не изменены.
Новый RX получает полный поток, самостоятельно находит начало, оценивает CFO,
фазу и полярность. PRBS7, физические и пакетные форматы, FEC, CRC и порог
Lab047 `0.030964473262429237` сохранены.
RF TX = 0. Git add/commit/push/pull/fetch не выполнялись.

View File

@@ -0,0 +1,450 @@
# Lab049: CRSF / ExpressLRS и отдельный SDR-видеоканал
Состояние на 8 сентября 2026 года. SOFTWARE + ARCHITECTURE ONLY, RF TX = 0.
Решение по результатам аудита: **сохранить стандартный ELRS для управления,
а SDR оптимизировать под изображения и объёмные данные**. Смешанная модель
CONTROL 20 Гц + TELEMETRY 10 Гц, каждый в полном видеокадре, остаётся
историческим стресс-тестом; она не определяет бюджет этой архитектуры.
## Источники и границы доказанного
Проверены официальный [последний релиз ExpressLRS 4.1.0](https://github.com/ExpressLRS/ExpressLRS/releases/tag/4.1.0)
от 17 июля 2026 года и его
[коммит a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6](https://github.com/ExpressLRS/ExpressLRS/commit/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6).
Версия исходников не означает, что такая прошивка уже стоит у пользователя.
Ссылки ниже закреплены на этом коммите; страницы документации проверены в дату отчёта.
Локальная база: main, HEAD `ac6cc792b896e34f7dd6d291955a0387fdafcf41`.
До работы уже были изменены журнал и индекс, присутствовали незакоммиченные
Lab049, её тесты и результаты, а также сторонние локальные файлы. Они сохранены.
README описывает старую точку Lab042 и диапазон 200–250 МГц; текущая постановка
пользователя уточняет будущий SDR-видеоканал как примерно 180 МГц.
Физические модели RadioMaster, ELRS TX/RX и их диапазон пока не указаны.
Поэтому приведено сравнение, а не назначение конкретных настроек оборудования.
Инвентаризация и аппаратная проверка не выполнялись. Совместимость системы
ниже — архитектурная, сопряжение с контроллером ровера ещё не реализовано.
## Что уже есть в проекте
Аудит README, PROJECT_LOG, LABS_INDEX, Lab033–Lab041, Lab047–048 и связанных
модулей дополняет [предыдущий аудит Lab049](lab049_audit.md).
| Код / лаборатории | Для чего создавался | Что сохраняется при отдельном ELRS |
|---|---|---|
| `protocol/link_packet.py`, `priority_scheduler.py`; Lab033 | Один абстрактный непрерываемый ресурс для двух направлений, EMERGENCY > CONTROL > TELEMETRY > VIDEO, последнее ожидающее состояние | Историческая модель и регрессии; не размещаем её CONTROL/TELEMETRY в бюджете SDR |
| `video_frame_scheduler.py`, Lab034–036 | Удаление устаревшего видео, допуск целого изображения, ограничение очереди, FEC-блоки в пределах изображения | Нужны принципы возраста и ограничения очереди; сопряжение с нынешним JPEG/PHY ещё предстоит |
| Lab037, `control_repetition.py` | Повторы управления, потери и видео в одном канале, удаление дубликатов | Тестовые сценарии потерь; собственные повторы не накладываются автоматически поверх ELRS |
| Lab038, `control_failsafe.py`, `emergency_ack.py` | Таймер свежей команды, остановка, подтверждение аварии | Локальный контроль потери управления и идемпотентная аварийная остановка остаются обязательными |
| Lab039, `two_stage_failsafe.py` | Две ступени замедления/торможения, кинематика | Автомат и сценарии; тормозные параметры реального ровера пока не измерены |
| Lab040, `persistent_emergency.py` | Намерение остановить сохраняется после ACK; отдельный подтверждённый сброс | Смысл аварийной защёлки и запрет самовозобновления; перенос наземного автомата на обычный пульт не выполнен |
| Lab041, `control_messages.py`, `session_state.py` | Сеансы, загрузки, эпохи, номера команд, защита от старых ACK/reset | Сохранить свойства; CRSF не предоставляет эти поля автоматически |
| Lab047–048, Lab049 IQ | JPEG как двоичный объект, независимый PHY/FEC/CRC, производительность | Доказательная база отдельного SDR-видеоканала |
Синтетические 64 байта TELEMETRY из Lab037 и собственные сообщения CONTROL
Lab041 не являются форматами CRSF. Модули `protocol/` и старые тесты не менялись.
## Одна схема системы
```mermaid
flowchart TB
subgraph RC[Управление и критическая телеметрия]
RM[RadioMaster: стики и переключатели] <-->|CRSF| ETX[Штатный ExpressLRS TX]
ETX <-->|ELRS OTA: поддерживаемый аппаратурой диапазон| ERX[Штатный ExpressLRS RX]
ERX <-->|CRSF UART| R[Контроллер ровера]
R --> S[Локальный таймер, аварийная защёлка, торможение]
S --> M[Приводы]
end
subgraph VIDEO[Независимый SDR video/data]
C[Камера] --> J[JPEG; будущий видеокодек]
J --> STX[SDR передатчик]
B[Объёмные данные ровера] --> STX
STX -->|Будущий RF около 180 МГц; лабораторная база 1400 МГц| SRX[SDR приёмник]
SRX --> D[Декодер изображения] --> O[Экран оператора]
end
```
При пропаже ELRS продолжающееся видео **не продлевает разрешение движения**.
Таймер, тормозной автомат и аварийная защёлка должны работать независимо от
очередей видео, загруженности декодера и доступности экрана.
Частота 180 МГц относится к будущему SDR-тракту, не к стандартному ELRS.
Это целевая архитектура; готовый двухканальный ровер пока не испытан.
## CRSF на проводных границах
По [спецификации TBS](https://github.com/tbs-fpv/tbs-crsf-spec/blob/main/crsf.md):
кадр содержит sync/address, длину, тип, данные и CRC8 (полином 0xD5).
Длина считает тип, данные и CRC, исключая первые два байта; полный кадр ≤64 байт.
Расширенный заголовок добавляет destination и origin.
| Тип | Содержание | Данные / полный кадр, байт |
|---|---|---:|
| 0x16 | 16 RC-каналов по 11 бит | 22 / 26 |
| 0x14 | Статистика связи | 10 / 14 |
| 0x08 | Батарея | 8 / 12 |
| 0x02 | GPS | 15 / 19 |
| 0x1E | Ориентация | 6 / 10 |
| 0x21 | Режим, строка с завершающим нулём | переменный |
| 0x28–0x2D, 0x32 | Обнаружение, параметры, команды/ACK | переменный, расширенный |
Пульт↔модуль: обычно однопроводный полудуплекс, 400 кбод, 8N1, 3,3 В;
приёмник↔контроллер: двухпроводный полный дуплекс, без инверсии,
в спецификации 416666 бод, 8N1, 3,0–3,3 В.
У ELRS штатная настройка UART приёмника — **420000 бод**:
[официальная Web UI](https://www.expresslrs.org/quick-start/webui/#receiver-options).
Настройки обеих сторон должны совпадать. Расчёт сериализации 26 байт при
420000 бод: 26×10/420000 = 0,619 мс; это только провод, не радиозадержка.
[SerialCRSF::sendRCFrame](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/src/rx-serial/SerialCRSF.cpp)
упаковывает текущие каналы в 0x16 только при `frameAvailable`.
Это не сквозная пересылка исходных CRSF-байтов через эфир: ELRS сначала
кодирует OTA, затем RX заново формирует CRSF. Статистика и сообщения
контроллера идут отдельными кадрами. Не все 16 выходных полей обновляются
по радио одновременно; часть может содержать состояние, полученное ранее.
Для обычных стиков специальная прошивка RadioMaster не требуется: нужен
вариант с ELRS либо совместимый внешний ELRS-модуль и штатная настройка CRSF.
Вариант RadioMaster с другим внутренним RF-модулем сам по себе ELRS не даёт.
Нужен RX с доступным CRSF UART, общий поддерживаемый диапазон и совместимая
версия ELRS; конкретную пару ещё надо выбрать. См.
[настройку передатчика](https://www.expresslrs.org/quick-start/transmitters/lua-howto/).
Программное сопряжение CRSF с ровером остаётся будущей задачей.
## ELRS в эфире
[OTA.h](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/lib/OTA/OTA.h)
задаёт OTA4 = **8 байт**, OTA8 = **13 байт**, включая служебные поля и CRC.
Это длина содержимого радиопакета ELRS, не весь физический расход LoRa/FLRC/GFSK.
RC-пакеты, DATA, SYNC и LINK имеют разные назначения; 26 байт CRSF не следует
подставлять вместо этих размеров. Поле DATA в
[telemetry_protocol.h](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/include/telemetry_protocol.h)
содержит **5 либо 10 байт**, а не 8/13 байт пользовательской нагрузки.
Точные периоды и Std взяты из
[ExpressLRS_AirRateConfig](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/src/common.cpp).
Таблица ниже рассчитана для установившегося соединения, без потерь,
без повторных режимов D/DK; RC означает возможности отправки, не измеренные обновления стиков.
| Режим | Доступность | OTA, байт | Период, мс | Std | TLM-слотов/с | RC-возможностей/с ≤ | Среднее / верхнее ожидание RC, мс |
|---|---|---:|---:|---:|---:|---:|---:|
| 25 | 900 | 8 | 40 | 1:8 | 3,125 | 21,875 | 25 / 80 |
| 50 | 900 и 2,4 | 8 | 20 | 1:16 | 3,125 | 46,875 | 11,250 / 40 |
| 100 | 900 | 8 | 10 | 1:32 | 3,125 | 96,875 | 5,313 / 20 |
| 100 Full | 900 и 2,4 | 13 | 10 | 1:32 | 3,125 | 96,875 | 5,313 / 20 |
| 150 | 2,4 | 8 | 6,666 | 1:32 | 4,688 | 145,327 | 3,541 / 13,332 |
| 200 | 900 | 8 | 5 | 1:64 | 3,125 | 196,875 | 2,578 / 10 |
| 250 | 2,4; 900 на LR1121 | 8 | 4 | 1:64 | 3,906 | 246,094 | 2,063 / 8 |
| 333 Full | 2,4 | 13 | 3,003 | 1:128 | 2,602 | 330,399 | 1,525 / 6,006 |
| 500 LoRa | 2,4 | 8 | 2 | 1:128 | 3,906 | 496,094 | 1,016 / 4 |
| F1000 / K1000 | 2,4, SX128X / LR1121 | 8 | 1 | 1:128 | 7,813 | 992,188 | 0,508 / 2 |
| K1000 Full | 900, LR1121 | 13 | 1 | 1:128 | 7,813 | 992,188 | 0,508 / 2 |
Это представительная, не исчерпывающая таблица. Также существуют F500,
900 200 Full на LR1121, D50, D250/D500, DK250/DK500 и двухдиапазонные X-режимы;
аппаратные ограничения описаны в
[официальном руководстве Packet Rate](https://www.expresslrs.org/quick-start/transmitters/lua-howto/#packet-rate-and-telemetry-ratio).
Для внешнего модуля 333 Гц и выше требуется скорость связи с пультом выше 400 кбод.
Не все режимы доступны одному и тому же RX.
Full повышает число полноразмерных каналов в OTA. В режиме 8ch каналы 1–8
передаются вместе; 16ch Rate/2 делит частоту пополам; 12ch Mixed оставляет
первые четыре быстрыми, остальные передаёт с половинной частотой.
Hybrid/Wide передают дополнительные переключатели по очереди.
Поэтому аварийный орган нельзя без проверки помещать в медленно обновляемую группу.
Источник: [Switch Configs, варианты 4.x](https://www.expresslrs.org/software/switch-config/).
D50 повторяет команду четыре раза в слотах по 5 мс; D250/DK250 — четыре раза
по 1 мс, D500/DK500 — два. Называть их 200/1000 новыми командами в секунду нельзя.
Расчётчик отвергает повторные режимы вместо ложного пересчёта.
## Задержка управления и телеметрия
Обозначения: T — период RF-слота, D — знаменатель 1:D, f=1/T.
При фиксированном D телеметрии выделяется 1/D слотов, RC остаётся не более
f×(D−1)/D возможностей. До произвольного ближайшего RF-слота среднее ожидание
T/2, верхняя граница T. Если исключить каждый D-й слот под TLM, промежутки
между RC равны T или 2T; при равномерной временной фазе среднее ожидание
T×(1/2+1/D), верхняя граница 2T. Это наши аналитические расчёты, проверенные
независимым перечислением интервалов в тестах.
[TX-планировщик](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/src/tx_main.cpp)
заменяет часть передач RC на SYNC и активный DATA uplink; телеметрийные
слоты освобождаются для обратного направления. Стандартный Std зависит от режима,
Race выключает телеметрию при вооружении, служебный обмен может временно
изменить отношение до 1:2. Следовательно, 2T — граница только упрощённого
расписания RC/TLM, **не гарантия реального ELRS**. Потери или разрыв связи
вообще не имеют такой конечной верхней границы доставки.
Пример 100 Full (T=10 мс), все величины ниже вычислены тем же расчётчиком:
| TLM | Доля слотов | TLM/с | RC/с ≤ | Ожидание RC среднее / верхнее, мс | DATA с учётом LINK ≤ байт/с |
|---|---:|---:|---:|---:|---:|
| Off | 0% | 0 | 100 | 5 / 10 | 0 |
| 1:128 | 0,78125% | 0,781 | 99,219 | 5,078 / 20 | 6,250 |
| 1:64 | 1,5625% | 1,563 | 98,438 | 5,156 / 20 | 12,500 |
| 1:32 Std | 3,125% | 3,125 | 96,875 | 5,313 / 20 | 25,000 |
| 1:16 | 6,25% | 6,250 | 93,750 | 5,625 / 20 | 54,167 |
| 1:8 | 12,5% | 12,500 | 87,500 | 6,250 / 20 | 116,667 |
| 1:4 | 25% | 25 | 75 | 7,500 / 20 | 241,667 |
| 1:2 | 50% | 50 | 50 | 10 / 20 | 492,000 |
Предварительный кандидат для программной модели ровера — **100 Full, 8ch,
TLM 1:8**, если выбранная пара его поддерживает: достаточно быстрые каналы
стиков/остановки и больше места служебным сообщениям, чем при Std.
Это не назначенная конфигурация и не доказательство сроков аварии/ACK.
Для 900 МГц при приоритете чувствительности следует сравнить 50 и обычный 100;
для 2,4 ГГц — 50/100 Full/150. Окончательный выбор требует моделей TX/RX,
диапазона, требований к дальности и отдельного измерения задержек/потерь.
Чувствительность действительно меняется: официальные ориентиры для 2,4 ГГц
50/100 Full/250/500 — −115/−112/−108/−105 dBm, для 900 МГц 50/100/100 Full —
−120/−117/−112 dBm. Это параметры режимов из
[официальной таблицы](https://www.expresslrs.org/software/mavlink/#throughput-per-rf-mode),
а не обещание дальности. ELRS-дальность и будущая дальность SDR на 180 МГц
проверяются отдельно; дальнее видео не компенсирует короткий радиус управления.
Полная цепь задержки управления: считывание стиков → цикл RadioMaster →
CRSF к TX → ожидание слота и OTA → RX → CRSF к роверу → цикл управления →
привод. Она **не измерена**. К рассчитанному ожиданию нельзя приписывать
название stick-to-rover latency.
## Сколько данных в штатной телеметрии и почему это не видео
Верхний предел одного тракта DATA: P×f/D байт/с, P=5 либо 10.
[RX-планировщик 4.1.0](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/src/rx_main.cpp)
чередует LINK и серию DATA. LINK занимает четыре байта статистикой,
оставляя 1/6 байт DATA. При постоянной насыщенной очереди:
B=max(1, floor(512×floor(f)/D/1000)−1), средний предел
C=(f/D)×(P−4/(B+1)). Это учёт слотов, а не гарантированный полезный поток:
CRC/служебные поля OTA уже вычтены размером P; добивка, подтверждения,
повторы DATA, потери и заголовки переносимых CRSF/MAVLink снижают результат.
При низкой частоте TLM цель LINK раз в 512 мс сама по себе не гарантируется.
Даже предельно щедрая оценка K1000 Full, 1:2 даёт 5000 байт/с на один тракт
до LINK; условное удвоение для двух DATA-трактов даёт 10000 байт/с. Уже
**JPEG 3 кадра/с требует 26172 байт/с**, то есть больше даже такого потолка.
[Официальные оценки MAVLink](https://www.expresslrs.org/software/mavlink/#throughput-per-rf-mode)
при 1:2: 100 Full около 435 байт/с, 333 Full около 1470,
F1000 около 2375; K1000 Full около 4420, Gemini около 8845 байт/с.
Последнее — 70,76 кбит/с, против 209,376 кбит/с одного JPEG payload при 3 кадрах/с.
Это документированные оценки специального режима MAVLink, не наши замеры
и не обещание доступного JPEG-сокета у обычного CRSF RX.
**STANDARD ELRS VIDEO CAPABILITY = INSUFFICIENT.**
Телеметрию разделяем так:
| Класс | Путь | Реализация и бюджет |
|---|---|---|
| Качество RC-связи: LQ, RSSI, SNR | Штатный ELRS LINK → RadioMaster; CRSF к роверу | Поддерживается ELRS, но не подтверждает выполнение команды |
| Напряжение/батарея | Контроллер → CRSF → ELRS DATA → RadioMaster | Формат 0x08 предусмотрен; датчик и выдачу значений должен реализовать контроллер |
| Аварийная защёлка, режим ровера, подтверждение критического действия | Тот же ELRS/CRSF обратный путь | Семантика, события, идентификаторы и отображение на пульте ещё не согласованы/реализованы; 0x21/0x32 не дают их автоматически |
| Большие диагностические структуры и сенсорные массивы | SDR data/video | Отдельный будущий ограниченный бюджет и политика возраста, нагрузка пока не задана |
Для масштаба: батарея 12 байт CRSF дважды в секунду требует 24 байт/с.
При 100 Full Std расчётный потолок после LINK всего 25 байт/с до прочих потерь:
запаса почти нет. При 1:8 потолок 116,667 байт/с. Предлагаемый начальный лимит
периодической служебной нагрузки — 50 байт/с **включая CRSF-заголовки**;
остаток не объявляется гарантированным: нужны измерения и резерв событий/повторов.
Критические события должны опережать периодические датчики в будущей очереди
контроллера. TLM 1:8 даёт слот раз в 80 мс: ACK за 50 мс здесь не обещается.
Остановка происходит локально по управляющему тракту и не ждёт обратного ACK.
Off/Race не подходят для требуемой постоянной критической телеметрии.
## Связь с безопасностью Lab038–Lab041
Здесь сформулирован контракт будущего сопряжения, код CRSF/bridge не написан.
1. Ровер стартует с запрещённым движением; после загрузки, пропажи RC или
аварии одно появление старого положения стиков не разрешает движение.
Нужны безопасное положение органов и явное новое разрешение оператора.
2. Команда остановки поступает через быстрый RC-орган ELRS/CRSF. Локальная
защёлка сохраняет остановку независимо от последующих стиков; ACK только
сообщает о ней. Снятие защёлки и разрешение движения — отдельные действия.
Обычный тумблер пульта не заменяет весь `PersistentEmergencyController`.
3. Таймер свежести управления не обновляется от JPEG, LINK statistics,
напряжения, DATA или любого произвольного CRSF-кадра. На потерю допустимого
управления реагируют существующие ступени торможения. Пороги Lab039
100/200 и 150/250 мс являются исследованными модельными вариантами,
не измеренными тормозными нормативами ровера; здесь они не изменены.
4. `ground_session_id`, `rover_boot_id`, `control_epoch`, `sequence_number`,
идентификаторы аварии/сброса Lab041 отсутствуют в RC 0x16. Локально
присвоенный номер приёма доказывает порядок UART-событий, но не свежесть
источника, защиту от повтора или подлинность команды. CRC этого не заменяет.
Binding/model match также нельзя объявлять эквивалентом автомата Lab041.
5. В [SendRCdataToRF](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/src/tx_main.cpp)
порог давности последнего RC от пульта — 1000000 мкс. При остановке
обновлений RadioMaster до этого порога TX может посылать прежнее состояние.
Приём регулярных UART RC-кадров не обеспечивает исходную свежесть за
100/200 мс. Сценарий требует отдельного решения и проверки всей цепи;
нормальное неизменное положение стика нельзя считать отказом только
потому, что значения равны предыдущим.
6. На возобновление соединения, reboot RX/контроллера, старый reset/ACK,
удерживаемый разрешающий тумблер и отсутствие обратной телеметрии нужны
воспроизводимые проверки. До них `SAFETY INTEGRATION = NOT VALIDATED`.
Автомат Lab041 нельзя просто обойти синтезом «нового сеанса» на каждый пакет.
Таким образом, отдельный ELRS устраняет конкуренцию с видеокадрами, но сам
по себе не доказывает безопасность готового ровера. Сохранённые модули и
их тесты остаются эталоном свойств для последующей реализации.
## Видео: фактическая упаковка контрольного JPEG
Объект [Lab047](../data/processed/lab047/tx_image.jpg): 8724 байта, RGB 320×240,
SHA-256 `e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`.
JPEG не перекодирован. [Расчётчик](../experiments/lab049_crsf_elrs_budget.py)
вызывает настоящие `build_object_frames` и `build_transmission_plan` из
[Lab046](../experiments/lab046_bpsk_object_transport.py) /
[Lab045](../experiments/lab045_hackrf_bpsk_fec.py), не генерируя IQ.
| Ступень на одно изображение | Объём |
|---|---:|
| JPEG | 8724 байта |
| 36 фрагментов: заголовки по 12 байт + 60 байт добивки последнего | 9216 байт нагрузки пакетов |
| 36 пакетов по 268 байт: ещё 12 байт заголовка/CRC на пакет | 9648 байт |
| Свёрточный FEC R=1/2, шесть хвостовых бит на пакет | 154800 кодированных бит |
| PHY-заголовки 144×36 | 5184 бит |
| Преамбулы 1280×36 | 46080 бит |
| Передаваемые биты BPSK, сумма | 206064 бит |
| Защитные интервалы + хвост фильтра (32+10)×36 | 1512 интервалов без новых бит |
| Полное занятие радио | **207576 символьных интервалов** |
Перемежитель длину не меняет. Внешний стирающий FEC старого видеопланировщика
Lab034 сюда дополнительно не добавлен: в доказанном потоке Lab047 его нет.
Радиосимвол BPSK несёт один передаваемый бит, но защитный интервал не является
битом. Поэтому ниже отдельно показаны поток передаваемых бит и минимальная
символьная скорость с учётом всего занятого времени. Это расход текущего
формата; он не переносится на ELRS и не включает новую модуляцию.
| Цель | JPEG, байт | Только JPEG, байт/с | Только JPEG, кбит/с | Передаваемые BPSK-биты, кбит/с | Требуемая PHY с защитными интервалами, ксимв/с | RX: изображений/с; RF-кадров/с | Максимум обслуживания RX на изображение | Предложенный предел полной задержки* |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| 3 кадра/с, промежуточно | 8724 | 26172 | 209,376 | 618,192 | **622,728** | 3; 108 | 333,33 мс | 333,33 мс |
| 5 кадров/с, следующий ориентир | 8724 | 43620 | 348,960 | 1030,320 | **1037,880** | 5; 180 | 200 мс | 200 мс |
| 10 кадров/с, дальнейший ориентир | 8724 | 87240 | 697,920 | 2060,640 | **2075,760** | 10; 360 | 100 мс | 100 мс |
*Предел полной задержки предложен как один период изображения. Это открытое
инженерное допущение, **не утверждённый пользователем предел безопасности и
не измеренная характеристика**. RX service budget — необходимая средняя
производительность последовательного обработчика, не разрешение занять этим
весь сквозной бюджет. Измерение хвостов распределения задержки также необходимо.
Числа PHY — нижние границы при отсутствии потерь, повторов и объёмной нагрузки,
весь ресурс отдан видео. На границе ёмкости нет запаса от роста очереди.
Произвольные 20 CONTROL + 10 TELEMETRY из SDR **не вычитаются**.
Когда появятся объёмные данные, расчётчик принимает их явно как расход
символьных интервалов в секунду. Если занято 20% ресурса, требуемая PHY
для того же видео возрастает в 1/0,8=1,25 раза — это пример, не выбранный резерв.
Точные требуемые скорости выше проверенных 200 ксимв/с; их работоспособность
и допустимое число выборок/символ при Fs=8 Мвыб/с не доказаны этой арифметикой.
## Текущая ёмкость и фактическая обработка
Замеры переиспользованы из
[independent/result.json](../data/processed/lab049/independent/result.json),
SHA-256 `8a2c799a9754cf439159ebd68f3d77212200a391ecfdbd3520c312a71a4e49b3`.
Полный тяжёлый IQ-опыт на этом архитектурном этапе повторно не запускался.
Расчётчик проверяет SHA JPEG, число кадров и согласие длительности с текущим PHY.
| Rs, ксимв/с | Полезно в идеале, кбит/с | JPEG в эфире, с | Идеальные кадры/с без RC-резерва | Измеренная обработка шумного IQ, с | Длительность IQ, с | Обработка/IQ | JPEG в этом шумном сценарии |
|---|---:|---:|---:|---:|---:|---:|---|
| 20 | 6,724 | 10,37880 | 0,09635 | 173,65057 | 10,45802 | 16,60454 | Нет, 6/36 |
| 50 | 16,811 | 4,15152 | 0,24088 | 5,72980 | 4,23074 | 1,35433 | Точный, 36/36 |
| 100 | 33,622 | 2,07576 | 0,48175 | 7,76244 | 2,15498 | 3,60210 | Точный, 36/36 |
| 200 | 67,245 | 1,03788 | 0,96350 | 8,17573 | 1,11710 | 7,31872 | Точный, 36/36 |
Шумной модели заданы одинаковые coded-bit Eb/N0=7 dB, CFO +10 Гц, фаза 0,73 рад,
чередование полярности и фиксированные seeds. Порог независимого приёмника
Lab047 сохранён: 0.030964473262429237. Это один программный сценарий,
не оценка дальности и не аппаратное подтверждение ускоренных режимов.
Исторический [Lab048](lab048_current_state.md): 15 с записи → 13,966058 с
обработки при 20 ксимв/с, отношение 0,931071. Это реальное время только на
той сохранённой записи. В нынешней шумной модели ни один режим не успевает;
для 5 кадров/с нужен RX как минимум 180 RF-кадров/с, а не нынешние 36 за
5,73–8,18 с. Производительность нельзя линейно экстраполировать по Rs.
При неизменном Fs вход остаётся 8 Мвыб/с, но число кандидатов и полезных
кадров возрастает. 50 ксимв/с — полезная точка для профилирования, а не цель видео.
## Движение и задержка изображения
Расчёт расстояния: d=(v/3,6)/fps. Все числа — метры между моментами съёмки
при постоянной скорости, не тормозной путь и не полный возраст изображения.
| Скорость | 1 кадр/с | 3 кадра/с | 5 кадров/с | 10 кадров/с |
|---|---:|---:|---:|---:|
| 10 км/ч | 2,778 | 0,926 | 0,556 | 0,278 |
| 15 км/ч | 4,167 | 1,389 | 0,833 | 0,417 |
| 25 км/ч | 6,944 | 2,315 | 1,389 | 0,694 |
При 25 км/ч ещё 200 мс возраста показанной сцены соответствуют 1,389 м
пройденного пути. Потому 3 кадра/с — только промежуточный минимум;
конечная цель **больше 3**, следующий практический ориентир 5, дальнейший 10.
| Участок сквозной задержки | Что известно сейчас |
|---|---|
| Съёмка/экспозиция/считывание камеры | Не измерено; сейчас готовый JPEG |
| Кодирование JPEG | Не измерено в живом тракте; перекодирование здесь не выполнялось |
| Радиоочередь | В расчёте ёмкости нулевая; живая очередь не реализована/не измерена |
| Передача RF | Рассчитанное время из фактической формы 10,37880 / 4,15152 / 2,07576 / 1,03788 с |
| Поиск и обработка RX | Измерены вместе со сборкой и JPEG decode в таблице выше |
| Декодирование JPEG | Входит в общее время RX; отдельно не выделено, второй раз не прибавляется |
| Очередь экрана/отрисовка | Не измерено |
Полная задержка L = capture + encode + queue + RF + RX + JPEG decode + display.
Сложение RF и имеющегося общего RX описывает последовательную обработку
после накопления записи, не измеренный потоковый конвейер. Для такого
варианта на 50 ксимв/с известная часть уже 4,15152+5,72980=9,88132 с,
плюс неизвестные стадии; реального измерения камера→экран пока нет.
Предложение для следующего проектирования: при 5 кадрах/с L≤200 мс,
например не более 100 мс на радио и оставшиеся 100 мс суммарно на прочие
стадии. Тогда по нынешнему формату требуется уже ≥2075,760 ксимв/с,
а не 1037,880: минимальный потоковый битрейт сам по себе задержку не обеспечивает.
Это иллюстративное распределение бюджета, которое предстоит проверить.
Удаление устаревших изображений ограничит возраст очереди, но не ускорит PHY.
## Если отказаться от отдельного ELRS
Перенос CRSF-семантики на SDR означал бы собственные радиомодем,
синхронизацию, дуплекс/планировщик, повторения, контроль потерь и весь путь
безопасности. Стандартный ELRS RX такой SDR-поток не декодирует; для
RadioMaster понадобился бы совместимый проводной адаптер/модуль.
Теряются готовая OTA-совместимость и доказанная работа штатной пары ELRS.
Это уже не стандартный ExpressLRS. Пользователь такой переход не запросил;
в данной Lab049 он не реализуется.
## Следующий этап и воспроизведение
Рекомендуемый Lab050 — **программный потоковый SDR-видеотракт и профиль RX**:
тот же JPEG, независимый непрерывный поиск, ограничение возраста/очереди,
измерение стадий обработки и проверка выполнимости 5 кадров/с по вычислениям
и PHY. Начать с профиля существующих 50/100/200 ксимв/с; отделить стоимость
поиска ложных кандидатов от FEC и сборки. Не объявлять ускорение успешным
до полного CRC/SHA и устойчивой очереди. Для CRSF отдельно подготовить
контракт безопасности и сценарии отказов; реализация сопряжения требует
следующей согласованной постановки и уточнения аппаратуры.
Позднее сравнить разрешение, grayscale, JPEG quality, ROI, более лёгкий
кодек и межкадровые/дельта-данные. Контрольная картинка не окончательна,
но в этой работе ни один из этих параметров не менялся.
```powershell
python experiments/lab049_crsf_elrs_budget.py
python -m pytest tests/test_lab049_crsf_elrs_budget.py -q -p no:cacheprovider
python tools/quick_gate.py
python -m pytest tests/ -q
git diff --check
```
Машинный результат: [crsf_elrs_budget.json](../data/processed/lab049/crsf_elrs_budget.json).
Он хранит источники, версию, допущения, SHA исходных замеров, расходы PHY,
таблицы ELRS, видео и движения. Старые JSON не перезаписаны.
Итог: 35 расчётных тестов PASS; quick_gate PASS — 606 тестов и 13 функциональных
проверок; полный pytest — 606 passed за 64,32 с, одно предупреждение о доступе
к `.pytest_cache`; git diff --check PASS. Дополнительный ruff недоступен:
модуль не установлен. Verifier независимо подтвердил числа и источники;
adversary подтвердил семь проверок сеансов и локальный watchdog при живом видео.
Сквозная безопасность CRSF/ELRS остаётся непроверенной, поскольку сопряжения нет.
RF TX = 0. HackRF и другое оборудование не запускались.
Git add/commit/push/pull/fetch не выполнялись. STOP.

View File

@@ -0,0 +1,219 @@
# Lab049: отдельный ELRS для управления и SDR для видео
Актуальная архитектурная постановка от 8 сентября 2026 года:
**RadioMaster → штатный ELRS → CRSF → контроллер ровера** для управления и
критической телеметрии; отдельный SDR — для изображений и объёмных данных.
Будущие примерно 180 МГц относятся только к SDR; аппаратная лабораторная
база остаётся 1400 МГц. Стандартный ELRS не объявляется работающим на 180 МГц.
Подробный [аудит CRSF/ELRS, схема и контракт безопасности](lab049_crsf_elrs_architecture.md)
сверен с официальным ExpressLRS 4.1.0. Модели аппаратуры пока не указаны,
сопряжение контроллера с CRSF и сквозная безопасность не реализованы/не проверены.
Сохраняются локальное торможение при потере управления, аварийная защёлка,
защита от самовозобновления. Видео не обновляет таймер управления.
Прежний общий CONTROL > TELEMETRY > VIDEO — исторический стресс-тест ниже.
Его 20 CONTROL и 10 TELEMETRY в секунду **не резервируют ёмкость SDR**.
Старые программные результаты и отрицательные вердикты сохранены.
| SDR, ксимв/с | JPEG 8724 байта в идеале, кадры/с | Полезно, кбит/с |
|---|---:|---:|
| 20 | 0,09635 | 6,724 |
| 50 | 0,24088 | 16,811 |
| 100 | 0,48175 | 33,622 |
| 200 | 0,96350 | 67,245 |
Требуется **больше 3 кадров/с**: 3 — промежуточный минимум, 5 — следующий
ориентир, 10 — дальнейший. Тот же JPEG в нынешнем PHY требует соответственно
622,728 / 1037,880 / 2075,760 ксимв/с с защитными интервалами, без запаса на
потери и объёмные данные. Только JPEG: 209,376 / 348,960 / 697,920 кбит/с.
Штатная ELRS-телеметрия для такого видео недостаточна; официальные источники,
расходы по ступеням, движение и бюджет полной задержки приведены в новом отчёте.
Новый [расчётчик](../experiments/lab049_crsf_elrs_budget.py) создаёт отдельный
[crsf_elrs_budget.json](../data/processed/lab049/crsf_elrs_budget.json),
переиспользует существующие независимые замеры IQ по SHA и не запускает RF.
Он считает ELRS-возможности слотов, не измеренную задержку стики→ровер.
35 новых тестов бюджета проходят; quick_gate: 606 тестов и 13 функциональных
проверок, полный pytest: 606 passed, git diff --check: PASS. Одно предупреждение
pytest относится к доступу к `.pytest_cache`; ruff не установлен.
Независимые verifier/adversary не обнаружили воспроизведённых расхождений.
Подробности общих проверок — в записи 038.
Следующий рекомендуемый Lab050: программное профилирование и потоковая
обработка SDR-видео с ограничением возраста очереди; затем отдельное сопряжение CRSF.
## Сохранённый независимый IQ-опыт и общий стресс-тест
Актуальный программный результат от 8 сентября 2026 года: **полностью пригодного режима нет**.
Максимальная проверенная скорость с точным JPEG в идеальном и фиксированном шумном сценариях —
200 ксимв/с. Полный приёмник в шумном сценарии не успевает в реальном времени ни в одном режиме.
Это результат одного воспроизводимого сценария, не универсальная оценка предела устойчивости BPSK.
Для отдельного будущего диагностического PHY-опыта выбран **50 ксимв/с, 160 выборок/символ**:
точный JPEG, полезная скорость в 2,5 раза выше базовой и наименьшая вычислительная перегрузка
среди точных шумных проходов (1,3543). Он не готов к непрерывному объединённому каналу:
блокировка 115,32 мс, перегрузка служебным трафиком и нарушенный аварийный срок.
Это кандидат для диагностики, не разрешение RF. 100 ксимв/с лучше по блокировке, но требует
3,60 длительности IQ для обработки; 200 ксимв/с — 7,32.
## Аудит и исправления
При входе в задачу Lab049 уже существовала без коммита. Старый результат сохранён как
`data/processed/lab049/frame_aligned_result.json`; запись 036 журнала не переписана.
Они измеряли заранее выровненные кадры без остаточного CFO. Новые данные —
`data/processed/lab049/result.json` и идентичный `independent/result.json`.
[Аудит](lab049_audit.md) содержит карту функций, проверку Lab033–Lab041 и воспроизведённые отказы.
Основной RX получает целый IQ-поток без TX-границ, payload, объекта или ожидаемого числа кадров.
Используются блочный поиск и строгий декодер Lab048: `lab046c_offline_recovery.stream_process_capture`.
Сохранён порог Lab047 `0.030964473262429237`. Адаптер последовательно задаёт Rs/SPS и предоставляет
чтение синтетического complex64; исходные константы и функции возвращаются через finally.
Старый локальный `receive_iq_frame` остаётся для регрессий формы, основной проход его не использует.
Fs = 8 000 000 выборок/с. Отношения Fs/Rs точные, дробные входы и нецелое samples/symbol
отвергаются. BPSK, PRBS7, PHY header, packet/fragment, CRC, свёрточный FEC R=1/2 K=7
(171/133 octal), tail bits и перемежитель сохранены. Новый планировщик не создан, protocol не изменён.
## Канал и объект
Один JPEG Lab047: 8724 байта, RGB 320×240, SHA-256
`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`; без перекодирования.
Каждый проход: JPEG → фрагменты → protocol packets → FEC → interleaver → BPSK/RRC →
CS8-квантование → IQ → независимый поиск → CFO/фаза/полярность → PHY/FEC/CRC → сборка → JPEG decode.
Идеальный канал не добавляет шум/CFO. Шумный: единый coded-bit Eb/N0=7 dB,
seed=49000+номер кадра, остаточный CFO +10 Гц, фаза 0,73 рад и чередование полярности.
Eb = sum(abs(IQ)**2)/число символов; sigma² каждой компоненты шума = Eb/(2×10**(7/10)).
Нормировка одинакова во всех режимах, параметры не подбирались до PASS.
Ведущий, межкадровые и конечный интервалы не кратны символу; фон имеет отдельные фиксированные seeds.
После добавления шума IQ не переквантован в CS8, чтобы не отсекать шум.
Кадр занимает 5766 символьных интервалов с фильтром и защитными интервалами; JPEG — 36 кадров.
Длительность IQ включает ещё 0,079217625 с фона. Полезная скорость считается по чистому времени
JPEG, отношение обработки — по всей длительности IQ. Измеряются поиск, декодирование, сборка и
JPEG decode; генерация канала и TX-сравнение после вердикта исключены. Все восемь проходов
завершились END_OF_STREAM. Первый пробный прогон с лимитами Lab046B 32/256 был неполным;
итоговые результаты получены после перехода на полный блочный путь с независимыми лимитами 4096/100000.
## Результаты IQ
| Метрика | 20 ксимв/с | 50 ксимв/с | 100 ксимв/с | 200 ксимв/с |
|---|---:|---:|---:|---:|
| Выборок/символ | 400 | 160 | 80 | 40 |
| Кадр, мс | 288.300 | 115.320 | 57.660 | 28.830 |
| Кадров JPEG | 36 | 36 | 36 | 36 |
| JPEG airtime, с | 10.37880 | 4.15152 | 2.07576 | 1.03788 |
| Полезно, байт/с, идеал | 840.560 | 2101.399 | 4202.798 | 8405.596 |
| Полезно, кбит/с, идеал | 6.724 | 16.811 | 33.622 | 67.245 |
| Идеал: кадры / SHA | 36/36 / да | 36/36 / да | 36/36 / да | 36/36 / да |
| Шум: кадры | 6/36 | 36/36 | 36/36 | 36/36 |
| До FEC, принятые кадры | 67 | 147 | 119 | 125 |
| После FEC, принятые кадры | 0 | 0 | 0 | 0 |
| CRC PASS / FAIL | 6 / 0 | 36 / 0 | 36 / 0 | 36 / 0 |
| Шум: JPEG SHA / decode | нет / нет | да / да | да / да | да / да |
| Ошибки JPEG байт / бит | не восстановлен | 0 / 0 | 0 / 0 | 0 / 0 |
| Обработка идеала, с | 8.73746 | 5.69848 | 7.36877 | 6.45915 |
| Обработка шума, с | 173.65057 | 5.72980 | 7.76244 | 8.17573 |
| Длительность IQ, с | 10.458018 | 4.230738 | 2.154978 | 1.117098 |
| Обработка / IQ, шум | 16.604540 | 1.354327 | 3.602096 | 7.318724 |
| Реальное время, шум | нет | нет | нет | нет |
| Статус | FAIL | PARTIAL | PARTIAL | PARTIAL |
На 20 ксимв/с 67 ошибок до FEC и 0 после относятся **только к шести принятым кадрам**.
JPEG не восстановлен; полезная скорость целых JPEG в шумном проходе равна нулю.
Остальные 30 кадров нельзя объявлять безошибочными. Проверено 2378 кандидатов, 2372
отклонены на PHY header. На 50/100/200 проверено 59/237/474 кандидата.
Кандидат, не достигший CRC, не записан как CRC FAIL. Причины сохранены в JSON.
Порог и шум после отказов не менялись. Все идеальные проходы имеют ноль ошибок до/после FEC.
Базовые 6,724 кбит/с согласуются с примерно 6,7 кбит/с Lab047. Выигрыш 2,5/5/10
рассчитан по фактической форме и числу кадров, а не обещан заранее.
Исторические аппаратные результаты Lab047/Lab048 не опровергнуты: здесь другой канал.
## Планирование и задержки
Переиспользован LATEST_STATE: EMERGENCY > CONTROL > TELEMETRY > VIDEO.
Проверка даёт VIDEO(active) → EMERGENCY → CONTROL → TELEMETRY → VIDEO.
Физический кадр не прерывается, но весь JPEG не удерживает канал.
CONTROL — штатные 36 байт, 20 Гц (Lab041); TELEMETRY — синтетические 64 байта,
10 Гц (Lab037–Lab041); EMERGENCY — штатный EmergencyStop, 34 байта, t=0,777 с.
Служебная нагрузка генерируется 2 с. Запас VIDEO 256 пакетов не исчерпывается в этом окне.
Каждый пакет модели занимает один полный PHY-кадр. Это модель занятости;
упаковка LinkPacket в аппаратный 256-байтный PHY payload не реализована.
Прикладные CONTROL/EMERGENCY сохраняют точные сериализованные байты.
Задержки не включают программное время RX, потери, повторы и привод; это не гарантия
времени физического торможения. Блокировка — остаток начатого VIDEO;
доставка EMERGENCY в худшей фазе — почти два кадра, включая саму команду.
Старое поле deadline в priority_probe сохранено как совместимое поле блокировки
с явным scope; классификация использует новый delivery flag.
| Метрика | 20 ксимв/с | 50 ксимв/с | 100 ксимв/с | 200 ксимв/с |
|---|---:|---:|---:|---:|
| CONTROL max VIDEO blocking, мс | 288.30 | 115.32 | 57.66 | 28.83 |
| EMERGENCY max VIDEO blocking, мс | 288.30 | 115.32 | 57.66 | 28.83 |
| EMERGENCY worst delivery, мс | 576.60 | 230.64 | 115.32 | 57.66 |
| CONTROL blocking <100 / <50 мс | нет / нет | нет / нет | да / нет | да / да |
| EMERGENCY delivery ≤50 мс | нет | нет | нет | нет |
Доставка **в активном окне 0–2 с**, min / median / max, мс. Заменённые и ещё не доставленные состояния сюда не входят:
| Метрика | 20 ксимв/с | 50 ксимв/с | 100 ксимв/с | 200 ксимв/с |
|---|---:|---:|---:|---:|
| CONTROL | 289.90 / 303.20 / 316.50 | 116.92 / 136.28 / 162.88 | 58.84 / 82.03 / 105.64 | 30.01 / 43.94 / 61.07 |
| EMERGENCY | 376.20 / 376.20 / 376.20 | 145.56 / 145.56 / 145.56 | 87.90 / 87.90 / 87.90 | 30.24 / 30.24 / 30.24 |
| TELEMETRY | нет доставленных | нет доставленных | нет доставленных | 33.84 / 45.54 / 89.69 |
| VIDEO | 288.30 / 288.30 / 288.30 | 115.32 / 115.32 / 115.32 | 57.66 / 57.66 / 57.66 | 28.83 / 850.49 / 1845.12 |
Доставка **до полного опустошения очереди**, отдельно от активной нагрузки:
| Метрика | 20 ксимв/с | 50 ксимв/с | 100 ксимв/с | 200 ксимв/с |
|---|---:|---:|---:|---:|
| CONTROL, мс | 289.90 / 303.20 / 331.40 | 116.92 / 139.48 / 216.08 | 58.84 / 83.84 / 105.64 | 30.01 / 43.73 / 61.07 |
| EMERGENCY, мс | 376.20 / 376.20 / 376.20 | 145.56 / 145.56 / 145.56 | 87.90 / 87.90 / 87.90 | 30.24 / 30.24 / 30.24 |
| TELEMETRY, мс | 644.70 / 644.70 / 644.70 | 356.40 / 356.40 / 356.40 | 183.42 / 183.42 / 183.42 | 33.84 / 45.54 / 89.69 |
| VIDEO, мс | 288.30 / 39352.95 / 76111.20 | 115.32 / 17009.70 / 31713.00 | 57.66 / 9485.07 / 16836.72 | 28.83 / 5434.46 / 9110.28 |
| Конец опустошения, с | 76.11120 | 31.71300 | 16.83672 | 9.11028 |
| Превышения CONTROL / EMERGENCY | 6 / 1 | 17 / 1 | 6 / 1 | 0 / 0 |
Очередь заменяет старое ожидающее состояние, но не удаляет пакет по deadline.
На 20/50/100 за активные 2 с доставлено 0 TELEMETRY. У 200 конкретная EMERGENCY
доставлена за 30,24 мс, но худшая фаза 57,66 мс всё равно нарушает срок 50 мс.
## Исторический бюджет единого канала — не бюджет целевой архитектуры
Прикладные CONTROL/TELEMETRY: 5760/5120 бит/с; вместе с LinkPacket до PHY —
10880/7680 бит/с. Физическая стоимость: 20/10 полных кадров/с, или
**115320/57660 символьных интервалов в секунду** BPSK, включая всю форму.
Эту стоимость нельзя прямо вычитать из JPEG-бит/с: остаток считается через время эфира.
JSON-поля reserved_link_bps 46080/23040 относятся к внутренним 288-байтным
представителям планировщика, а не к физическому кодированному потоку.
| Метрика | 20 ксимв/с | 50 ксимв/с | 100 ксимв/с | 200 ксимв/с |
|---|---:|---:|---:|---:|
| CONTROL, запрошено % эфира | 576.60 | 230.64 | 115.32 | 57.66 |
| TELEMETRY, запрошено % эфира | 288.30 | 115.32 | 57.66 | 28.83 |
| EMERGENCY, событие/2с, % эфира | 14.415 | 5.766 | 2.883 | 1.442 |
| VIDEO после C+T, кбит/с | 0.000 | 0.000 | 0.000 | 9.085 |
| Теоретические JPEG/с после C+T | 0.00000 | 0.00000 | 0.00000 | 0.13017 |
| VIDEO после C+T+E, кбит/с | 0.000 | 0.000 | 0.000 | 8.115 |
Процент больше 100 означает перегрузку, а не фактически переданные данные.
Только 200 оставляет VIDEO: 9,085 кбит/с и около 0,1302 JPEG/с;
с одной EMERGENCY за 2 с — 8,116 кбит/с. Это потенциал модели, не видеопоток:
вычислительная перегрузка и аварийный срок остаются.
## Проверки предыдущего IQ-этапа
Независимые verifier/adversary воспроизвели прежние отказы и подтвердили исправления.
Verifier: 24 целевых теста PASS, независимый пересчёт JSON совпал.
Adversary: правильный отрицательный аварийный вердикт и отдельное активное окно.
- python tools/quick_gate.py: PASS, 571 тест ядра, 12 функциональных проверок.
- python -m pytest tests/ -q: 571 passed, 0 failed; одно предупреждение о запрете записи .pytest_cache.
- git diff --check: PASS.
RF TX = 0. HackRF не запускался. Git add/commit/push/pull/fetch не выполнялись.
STOP. Следующий этап требует отдельной постановки: вычислительная перегрузка,
бюджет и задержки перед аппаратным опытом. QPSK, новый FEC, реальное видео и
переход на 180 МГц в этой работе не выполнялись.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,304 @@
"""Lab049: раздельные бюджеты штатного ELRS и SDR-видеоканала, без RF.
Формулы описывают возможности слотов без потерь, а не полную задержку
управления. Исходные интервалы зафиксированы по ExpressLRS 4.1.0.
Никакие интерфейсы оборудования не открываются.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
import hashlib
import json
import math
from pathlib import Path
import sys
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
ROOT = Path(__file__).resolve().parents[1]
ELRS_VERSION = "4.1.0"
ELRS_COMMIT = "a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6"
SOURCE_ROOT = f"https://github.com/ExpressLRS/ExpressLRS/blob/{ELRS_COMMIT}/src/"
SOURCES = {
"rates_and_link_burst": SOURCE_ROOT + "src/common.cpp",
"ota_sizes": SOURCE_ROOT + "lib/OTA/OTA.h",
"data_sizes": SOURCE_ROOT + "include/telemetry_protocol.h",
"tx_scheduler": SOURCE_ROOT + "src/tx_main.cpp",
"rx_scheduler": SOURCE_ROOT + "src/rx_main.cpp",
"crsf": "https://github.com/tbs-fpv/tbs-crsf-spec/blob/main/crsf.md",
"mavlink_estimates": "https://www.expresslrs.org/software/mavlink/#throughput-per-rf-mode",
}
TELEMETRY_DENOMINATORS = (128, 64, 32, 16, 8, 4, 2)
JPEG_SHA256 = "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a"
def _positive(value: float, name: str) -> None:
if isinstance(value, bool) or not math.isfinite(value) or value <= 0:
raise ValueError(f"{name}: требуется конечное положительное число")
@dataclass(frozen=True)
class ElrsMode:
name: str
interval_us: int
ota_packet_bytes: int
standard_telemetry_denominator: int
repetitions: int = 1
def __post_init__(self) -> None:
if type(self.interval_us) is not int or self.interval_us <= 0:
raise ValueError("Интервал должен быть положительным целым числом микросекунд")
if type(self.ota_packet_bytes) is not int or self.ota_packet_bytes not in (8, 13):
raise ValueError("Поддерживаются OTA4=8 и OTA8=13 байт")
if (type(self.standard_telemetry_denominator) is not int
or self.standard_telemetry_denominator not in TELEMETRY_DENOMINATORS):
raise ValueError("Неизвестное штатное отношение телеметрии")
if type(self.repetitions) is not int or self.repetitions < 1:
raise ValueError("Число повторов должно быть положительным целым")
# Отдельные представительные режимы; наличие зависит от микросхемы и диапазона.
ELRS_MODES = (
ElrsMode("900: 25", 40_000, 8, 8),
ElrsMode("900/2.4: 50", 20_000, 8, 16),
ElrsMode("900: 100", 10_000, 8, 32),
ElrsMode("900/2.4: 100 Full", 10_000, 13, 32),
ElrsMode("2.4: 150", 6_666, 8, 32),
ElrsMode("900: 200", 5_000, 8, 64),
ElrsMode("2.4 / LR1121 900: 250", 4_000, 8, 64),
ElrsMode("2.4: 333 Full", 3_003, 13, 128),
ElrsMode("2.4: 500", 2_000, 8, 128),
ElrsMode("2.4: F1000 / K1000", 1_000, 8, 128),
ElrsMode("LR1121 900: K1000 Full", 1_000, 13, 128),
)
@dataclass(frozen=True)
class ElrsBudget:
packet_interval_ms: float
rf_slot_rate_hz: float
telemetry_slot_fraction: float
telemetry_slots_per_second: float
rc_opportunities_per_second_upper_bound: float
next_rf_slot_wait_mean_ms: float
next_rf_slot_wait_supremum_ms: float
next_rc_opportunity_wait_mean_ms: float
next_rc_opportunity_wait_supremum_ms: float
data_bytes_per_full_telemetry_packet: int
data_ceiling_bytes_per_second: float
data_ceiling_with_link_stats_bytes_per_second: float
data_burst_packets: int
def elrs_budget(mode: ElrsMode, telemetry_denominator: int | None) -> ElrsBudget:
"""Один TLM-слот из D; None означает Off, а не отношение 1:1.
Начальная фаза равномерна по времени. RC-слоты имеют интервалы T
(D-2 раза) и 2T (один раз) в цикле DT. Не учитываются SYNC, DATA
uplink, потери, LBT, мультиплексирование каналов и очередь UART.
Повторные D/DK-режимы требуют иной модели и здесь отвергаются.
"""
if mode.repetitions != 1:
raise ValueError("D/DK: частота RF-слотов не равна частоте новых команд")
if telemetry_denominator is not None and (
type(telemetry_denominator) is not int
or telemetry_denominator not in TELEMETRY_DENOMINATORS
):
raise ValueError("Отношение должно быть Off или 1:128 ... 1:2")
interval_ms = mode.interval_us / 1000
slot_rate = 1_000_000 / mode.interval_us
fraction = 0.0 if telemetry_denominator is None else 1 / telemetry_denominator
telemetry_rate = slot_rate * fraction
data_bytes = 5 if mode.ota_packet_bytes == 8 else 10
burst = 0
with_stats = 0.0
if telemetry_denominator is not None:
# Точное целочисленное вычисление updateTelemetryBurst/common.cpp.
integer_rate = 1_000_000 // mode.interval_us
burst = max(1, 512 * integer_rate // telemetry_denominator // 1000 - 1)
# LINK содержит 4 байта статистики, оставляя DATA 1 либо 6 байт.
with_stats = telemetry_rate * (data_bytes - 4 / (burst + 1))
return ElrsBudget(
interval_ms, slot_rate, fraction, telemetry_rate,
slot_rate * (1 - fraction), interval_ms / 2, interval_ms,
interval_ms * (0.5 + fraction),
interval_ms * (1 if telemetry_denominator is None else 2),
data_bytes, telemetry_rate * data_bytes, with_stats, burst,
)
@dataclass(frozen=True)
class ImageAccounting:
jpeg_bytes: int
rf_frame_count: int
fragment_header_bytes_total: int
padding_bytes_total: int
packet_bytes_total: int
packet_overhead_bytes_total: int
fec_coded_bits_total: int
phy_header_bits_total: int
preamble_bits_total: int
transmitted_bpsk_bits_total: int
guard_and_filter_intervals_total: int
symbol_intervals_total: int
def image_accounting(data: bytes) -> ImageAccounting:
"""Получить расходы из настоящих планов Lab046/Lab045, без формы IQ."""
if not data:
raise ValueError("Объект не должен быть пустым")
from experiments import lab046_bpsk_object_transport as lab046
frames = lab046.build_object_frames(data)
plans = [frame.plan for frame in frames]
payload_bytes = sum(len(plan.payload) for plan in plans)
packet_bytes = sum(len(plan.packet) for plan in plans)
transmitted_bits = sum(plan.frame_symbol_count for plan in plans)
waveform_samples = sum(plan.waveform_sample_count for plan in plans)
symbol_intervals, remainder = divmod(waveform_samples, lab046.lab045.SAMPLES_PER_SYMBOL)
if remainder:
raise ValueError("Форма не занимает целое число символьных интервалов")
return ImageAccounting(
len(data), len(frames), len(frames) * lab046.fragments.FRAGMENT_HEADER_SIZE,
payload_bytes - sum(frame.encoded_payload_length for frame in frames),
packet_bytes, packet_bytes - payload_bytes,
sum(len(plan.coded_bits) for plan in plans),
sum(len(plan.phy_header_bits) for plan in plans),
sum(plan.frame_symbol_count - len(plan.post_preamble_bits) for plan in plans),
transmitted_bits, symbol_intervals - transmitted_bits, symbol_intervals,
)
def jpeg_payload_bitrate(jpeg_bytes: int, frames_per_second: float) -> float:
if type(jpeg_bytes) is not int or jpeg_bytes <= 0:
raise ValueError("Размер JPEG должен быть положительным целым")
_positive(frames_per_second, "Частота изображений")
return jpeg_bytes * 8 * frames_per_second
def distance_per_frame(speed_kmh: float, frames_per_second: float) -> float:
_positive(frames_per_second, "Частота изображений")
if isinstance(speed_kmh, bool) or not math.isfinite(speed_kmh) or speed_kmh < 0:
raise ValueError("Скорость должна быть конечной и неотрицательной")
return speed_kmh / 3.6 / frames_per_second
def video_requirement(accounting: ImageAccounting, frames_per_second: float) -> dict:
"""Нижние границы без потерь и объёмных данных; задержка — предложение."""
payload_bits = jpeg_payload_bitrate(accounting.jpeg_bytes, frames_per_second)
return {
"target_fps": frames_per_second,
"jpeg_bytes": accounting.jpeg_bytes,
"payload_bytes_per_second": payload_bits / 8,
"payload_kbit_per_second": payload_bits / 1000,
"transmitted_bpsk_kbit_per_second": accounting.transmitted_bpsk_bits_total * frames_per_second / 1000,
"minimum_phy_symbol_rate_including_guards": accounting.symbol_intervals_total * frames_per_second,
"rx_images_per_second_minimum": frames_per_second,
"rx_rf_frames_per_second_minimum": accounting.rf_frame_count * frames_per_second,
"rx_service_budget_ms_per_image": 1000 / frames_per_second,
"proposed_e2e_image_latency_limit_ms": 1000 / frames_per_second,
"e2e_limit_is_approved_or_measured": False,
"distance_per_image_m": {str(speed): distance_per_frame(speed, frames_per_second) for speed in (10, 15, 25)},
}
def sdr_capacity(accounting: ImageAccounting, symbol_rate: float,
*, bulk_symbol_intervals_per_second: float = 0.0) -> dict:
"""Идеальная ёмкость видео; резерв только под явно заданные SDR-данные."""
_positive(symbol_rate, "Символьная скорость")
if (isinstance(bulk_symbol_intervals_per_second, bool)
or not math.isfinite(bulk_symbol_intervals_per_second)
or not 0 <= bulk_symbol_intervals_per_second <= symbol_rate):
raise ValueError("Резерв SDR-данных должен лежать между нулём и ёмкостью")
available = symbol_rate - bulk_symbol_intervals_per_second
fps = available / accounting.symbol_intervals_total
return {
"symbol_rate": symbol_rate,
"jpeg_airtime_seconds": accounting.symbol_intervals_total / symbol_rate,
"ideal_video_fps": fps,
"ideal_payload_kbit_per_second": accounting.jpeg_bytes * 8 * fps / 1000,
"sdr_rc_reserved_symbol_intervals_per_second": 0,
"bulk_reserved_symbol_intervals_per_second": bulk_symbol_intervals_per_second,
"is_measured_streaming_fps": False,
}
def run_functional_tests() -> None:
assert elrs_budget(ELRS_MODES[1], 16).telemetry_slots_per_second == 3.125
assert jpeg_payload_bitrate(8724, 5) == 348_960
assert math.isclose(distance_per_frame(25, 5), 25 / 18)
def main() -> None:
run_functional_tests()
jpeg_path = ROOT / "data/processed/lab047/tx_image.jpg"
measurement_path = ROOT / "data/processed/lab049/independent/result.json"
jpeg = jpeg_path.read_bytes()
if len(jpeg) != 8724 or hashlib.sha256(jpeg).hexdigest() != JPEG_SHA256:
raise ValueError("Контрольный JPEG отличается от Lab047")
accounting = image_accounting(jpeg)
measurement_bytes = measurement_path.read_bytes()
measurements = json.loads(measurement_bytes)
if measurements["jpeg"]["sha256"] != JPEG_SHA256:
raise ValueError("Замеры относятся к другому JPEG")
modes = []
for measured in measurements["modes"]:
if measured["jpeg_rf_frames"] != accounting.rf_frame_count:
raise ValueError("Число физических кадров изменилось")
if not math.isclose(measured["jpeg_airtime_seconds"] * measured["symbol_rate"],
accounting.symbol_intervals_total, rel_tol=1e-12):
raise ValueError("Физический формат отличается от сохранённых замеров")
row = sdr_capacity(accounting, measured["symbol_rate"])
row["reused_noisy_measurement"] = {
key: measured["noisy"][key] for key in (
"receiver_processing_seconds", "capture_equivalent_seconds",
"processing_to_capture_ratio", "valid_frames", "sha_match", "object_complete",
)
}
modes.append(row)
result = {
"stage": "Lab049 CRSF/ELRS architecture and separate SDR video budget",
"source_checked_date": "2026-09-08",
"elrs_version": ELRS_VERSION, "elrs_commit": ELRS_COMMIT, "sources": SOURCES,
"jpeg_sha256": JPEG_SHA256, "accounting": asdict(accounting),
"measurement_provenance": {
"path": measurement_path.relative_to(ROOT).as_posix(),
"sha256": hashlib.sha256(measurement_bytes).hexdigest(),
"new_iq_measurement_performed": False,
},
"assumptions": {
"elrs_hardware_model_and_band_confirmed": False,
"elrs_budgets_are_lossless_slot_opportunities_not_e2e_latency": True,
"elrs_data_capacities_are_upper_bounds_not_application_goodput": True,
"elrs_repetitions_in_calculator": 1,
"sdr_control_telemetry_reservation": 0,
"video_loss_retries_and_bulk_load": 0,
"proposed_image_latency": "one image period; engineering proposal, not a measured or approved safety limit",
},
"elrs_standard_ratio_modes": [
{"mode": asdict(mode), "budget": asdict(elrs_budget(mode, mode.standard_telemetry_denominator))}
for mode in ELRS_MODES
],
"elrs_100full_ratio_comparison": [
{"denominator": denominator, "budget": asdict(elrs_budget(ELRS_MODES[3], denominator))}
for denominator in (*TELEMETRY_DENOMINATORS, None)
],
"elrs_maximum_single_data_ceiling": asdict(elrs_budget(ELRS_MODES[-1], 2)),
"video_requirements": [video_requirement(accounting, fps) for fps in (3, 5, 10)],
"distance_per_image_m": {str(speed): {str(fps): distance_per_frame(speed, fps)
for fps in (1, 3, 5, 10)} for speed in (10, 15, 25)},
"sdr_modes": modes,
"rf_tx": 0, "hardware_access_performed": False,
}
output = ROOT / "data/processed/lab049/crsf_elrs_budget.json"
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(output.relative_to(ROOT).as_posix())
print(json.dumps({"accounting": asdict(accounting), "targets": result["video_requirements"],
"sdr_modes": modes}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,190 @@
"""Полный независимый IQ-проход Lab049 штатным приёмником Lab046B.
Адаптация старых лабораторий выполняется только в последовательном программном
процессе. Контекст возвращает константы и очищает зависящие от них кэши даже
при исключении. Аппаратные функции здесь не вызываются.
"""
from __future__ import annotations
from contextlib import contextmanager
from io import BytesIO
from pathlib import Path
import tempfile
import time
from types import SimpleNamespace
import numpy as np
from PIL import Image
from experiments import lab046b_continuous_iq as receiver
from experiments import lab046c_offline_recovery as offline
from experiments import lab049_bpsk_symbol_rate as lab
@contextmanager
def receiver_mode(mode: lab.PhyMode):
"""Временно задать только Rs и Fs/Rs для последовательного офлайн RX."""
modules = (lab.lab045, lab.lab045.lab044, lab.lab045.lab044.lab043)
originals = [(module, module.SYMBOL_RATE, module.SAMPLES_PER_SYMBOL)
for module in modules]
caches = (lab.lab045.lab044._preamble_reference_samples,
lab.lab045.lab044.lab043._matched_filter_taps,
lab.lab045.lab044.lab043._carrier_downconversion_vector)
try:
for module in modules:
module.SYMBOL_RATE = mode.symbol_rate
module.SAMPLES_PER_SYMBOL = mode.samples_per_symbol
for cache in caches:
cache.cache_clear()
yield
finally:
for module, rate, samples_per_symbol in originals:
module.SYMBOL_RATE = rate
module.SAMPLES_PER_SYMBOL = samples_per_symbol
for cache in caches:
cache.cache_clear()
def receive_stream(iq_samples: np.ndarray, mode: lab.PhyMode):
"""Поиск, CFO, PHY, FEC и CRC без границ TX или числа кадров."""
with receiver_mode(mode):
# У синтетического канала после добавления AWGN формат complex64.
# Подменяется только чтение IQ; блочный поиск и строгий декодер Lab048
# выполняются без изменений. Повторное CS8-квантование отсекло бы шум.
original_validate = offline._validate_capture
original_window = offline.lab046c._cs8_window
samples = np.asarray(iq_samples, dtype=np.complex64)
if samples.ndim != 1 or not samples.size or not np.all(np.isfinite(samples)):
raise ValueError("Нужен конечный одномерный IQ-поток")
try:
offline._validate_capture = lambda unused: (samples.view(np.float32), samples.size)
offline.lab046c._cs8_window = (
lambda raw, start, end: raw[2 * start:2 * end].view(np.complex64)
)
coarse, strict = offline.stream_process_capture(
Path("synthetic_iq_in_memory"),
offline.CandidateRule("FIXED_LAB047", 0.999,
lab.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD),
max_candidates=100_000, max_valid_frames=4096,
)
finally:
offline._validate_capture = original_validate
offline.lab046c._cs8_window = original_window
counters = strict.counters
return SimpleNamespace(
termination_reason=("END_OF_STREAM" if strict.termination_reason == "END_OF_CAPTURE"
else strict.termination_reason),
accepted_frames=strict.accepted_frames,
crc_pass_count=counters.crc_pass, crc_fail_count=counters.crc_fail,
phy_header_pass_count=counters.phy_header_pass,
fec_pass_count=counters.fec_pass,
preamble_candidates=coarse.candidate_count,
processed_candidates=counters.candidates_processed,
rejected_candidates=counters.candidates_processed - counters.valid_frames,
rejected_reasons=tuple(outcome.reason for outcome in strict.outcomes if not outcome.accepted),
)
def run_roundtrips(jpeg: bytes, mode: lab.PhyMode) -> tuple[dict, dict]:
"""Сформировать целый поток, завершить RX, затем открыть TX-диагностику."""
frames = tuple(lab.lab046.build_object_frames(jpeg, lab.lab047.OBJECT_ID))
frame_sizes = [lab.frame_waveform_sample_count(frame.plan, mode) for frame in frames]
# Не кратные символу интервалы известны только генератору канала.
gaps = [12007 + (index * 7919) % 10009 for index in range(len(frames) + 1)]
sample_count = sum(frame_sizes) + sum(gaps)
results = []
with tempfile.TemporaryDirectory(prefix="lab049_iq_") as temporary:
for noisy in (False, True):
path = Path(temporary) / "stream.c64"
iq_stream = np.memmap(path, dtype=np.complex64, mode="w+", shape=(sample_count,))
sigmas = []
cursor = gaps[0]
for index, frame in enumerate(frames):
iq = lab.lab045.lab044.signed_iq_bytes_to_complex64(
lab.build_parameterized_tx_cs8(frame.plan, mode)
)
if noisy:
# Одинаковые остаточный CFO, фаза и чередование полярности.
indexes = np.arange(iq.size, dtype=np.float64)
iq *= np.exp(1j * (0.73 + (index % 2) * np.pi
+ 2 * np.pi * 10.0 * indexes / mode.sample_rate_hz)).astype(np.complex64)
iq, sigma = lab.add_coded_bit_ebn0_noise(
iq, frame.plan.frame_symbol_count,
lab.NOISY_CODED_BIT_EBN0_DB, lab.NOISE_BASE_SEED + index,
)
sigmas.append(sigma)
random_generator = np.random.default_rng(149000 + index)
gap_start = cursor - gaps[index]
gap = sigma * (random_generator.standard_normal(gaps[index])
+ 1j * random_generator.standard_normal(gaps[index]))
iq_stream[gap_start:cursor] = gap.astype(np.complex64)
iq_stream[cursor:cursor + iq.size] = iq
cursor += iq.size + gaps[index + 1]
if noisy:
random_generator = np.random.default_rng(249000)
iq_stream[-gaps[-1]:] = (sigmas[-1] * (
random_generator.standard_normal(gaps[-1])
+ 1j * random_generator.standard_normal(gaps[-1])
)).astype(np.complex64)
iq_stream.flush()
started = time.perf_counter()
received = receive_stream(iq_stream, mode)
assembler = lab.lab046.ObjectAssembler()
for item in received.accepted_frames:
payload = item.reception.payload_bytes
if payload is not None:
assembler.offer(lab.lab046.recover_fragment_from_payload(payload))
recovered = assembler.recovered_object()
jpeg_decode = False
if recovered is not None:
with Image.open(BytesIO(recovered)) as image:
image.load()
jpeg_decode = (image.format, image.size, image.mode) == (
"JPEG", (320, 240), "RGB")
elapsed = time.perf_counter() - started
# Всё ниже находится после независимого решения и сборки.
accumulator = lab._RoundtripAccumulator(assembler, processing_seconds=elapsed,
phy_header_pass=received.phy_header_pass_count,
phy_header_fail=received.processed_candidates - received.phy_header_pass_count,
fec_pass=received.fec_pass_count, crc_pass=received.crc_pass_count,
fec_fail=received.phy_header_pass_count - received.fec_pass_count,
crc_not_reached=(received.processed_candidates
- received.crc_pass_count - received.crc_fail_count),
crc_fail=received.crc_fail_count, noise_sigmas=sigmas)
diagnosed = 0
by_index = {frame.fragment_index: frame for frame in frames}
for item in received.accepted_frames:
fragment = lab.lab046.recover_fragment_from_payload(item.reception.payload_bytes)
if fragment is None or fragment.fragment_index not in by_index:
continue
diagnostic = lab.lab045.diagnostic_pre_and_post_fec(
item.reception, item.post_preamble_soft, by_index[fragment.fragment_index].plan)
accumulator.pre_fec_errors += int(diagnostic["pre_fec_coded"]["bit_errors"])
accumulator.post_fec_errors += int(diagnostic["post_fec_packet"]["bit_errors"])
diagnosed += 1
summary = lab._roundtrip_summary(
accumulator, jpeg, len(frames), sample_count / mode.sample_rate_hz)
summary.update({
"receiver": "lab046c_offline_recovery.stream_process_capture (Lab048)",
"candidate_threshold": lab.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD,
"frame_aligned_iq": False, "tx_offsets_provided": False,
"residual_cfo_hz": 10.0 if noisy else 0.0,
"phase_rad": 0.73 if noisy else 0.0,
"polarity_alternates": noisy,
"jpeg_decode": jpeg_decode,
"preamble_candidates": received.preamble_candidates,
"processed_candidates": received.processed_candidates,
"rejected_candidates": received.rejected_candidates,
"rejected_reasons": list(received.rejected_reasons),
"termination_reason": received.termination_reason,
"diagnosed_frames": diagnosed,
"error_counts_cover_complete_object": diagnosed == len(frames),
"estimated_cfo_hz": [item.estimated_cfo_hz for item in received.accepted_frames],
"payload_airtime_seconds": sum(frame_sizes) / mode.sample_rate_hz,
"rf_tx": 0,
})
results.append(summary)
del iq_stream
path.unlink()
return results[0], results[1]

View File

@@ -0,0 +1,211 @@
from __future__ import annotations
from functools import lru_cache
import inspect
import numpy as np
import pytest
from experiments import lab045_hackrf_bpsk_fec as lab045
from experiments import lab049_bpsk_symbol_rate as lab049
from protocol.control_messages import ControlStateMessage, EmergencyStop, decode_message
from protocol.link_packet import TrafficClass, decode_link_packet
from protocol.priority_scheduler import QueuedPacket
@lru_cache(maxsize=1)
def _plan() -> lab045.TransmissionPlan:
payload = bytes((index * 49 + 7) & 0xFF for index in range(256))
return lab045.build_transmission_plan(payload)
def _frame_duration(symbol_rate: int) -> float:
mode = lab049.phy_mode(symbol_rate)
return lab049.frame_waveform_sample_count(_plan(), mode) / mode.sample_rate_hz
def test_parameterized_symbol_rates_have_exact_samples_per_symbol() -> None:
assert {
rate: lab049.phy_mode(rate).samples_per_symbol
for rate in lab049.TESTED_SYMBOL_RATES
} == {20_000: 400, 50_000: 160, 100_000: 80, 200_000: 40}
def test_non_integer_samples_per_symbol_is_rejected() -> None:
with pytest.raises(ValueError, match="целым"):
lab049.phy_mode(30_000)
def test_20ksymbol_waveform_is_byte_exact_lab045_result() -> None:
assert lab049.build_parameterized_tx_cs8(
_plan(),
lab049.phy_mode(20_000),
) == lab045.build_tx_cs8(_plan())
def test_20ksymbol_frame_duration_preserves_lab047_baseline() -> None:
mode = lab049.phy_mode(20_000)
assert lab049.frame_waveform_sample_count(_plan(), mode) == _plan().waveform_sample_count
assert _frame_duration(20_000) == _plan().airtime_seconds
def test_50ksymbol_iq_roundtrip() -> None:
result = lab049.single_frame_roundtrip(50_000)
assert result == {
"phy_header_pass": True,
"fec_pass": True,
"crc": lab045.CRC_PASS,
"payload_match": True,
}
def test_100ksymbol_iq_roundtrip() -> None:
result = lab049.single_frame_roundtrip(100_000)
assert result == {
"phy_header_pass": True,
"fec_pass": True,
"crc": lab045.CRC_PASS,
"payload_match": True,
}
def test_scheduler_priority_order_is_reused() -> None:
result = lab049.priority_probe(_frame_duration(100_000))
assert result["priority_pass"]
assert result["transmission_order"] == [
"VIDEO",
"EMERGENCY",
"CONTROL",
"TELEMETRY",
"VIDEO",
]
def test_video_is_not_interrupted_but_control_enters_at_next_boundary() -> None:
duration = _frame_duration(100_000)
result = lab049.priority_probe(duration)
assert result["first_video_not_preempted"]
assert result["control_max_video_blocking_ms"] == duration * 1000.0
def test_emergency_has_highest_priority_after_active_frame() -> None:
result = lab049.priority_probe(_frame_duration(100_000))
assert result["transmission_order"][1] == "EMERGENCY"
assert not result["emergency_deadline_met_in_worst_phase"]
assert lab049.priority_probe(_frame_duration(200_000))[
"emergency_deadline_met_in_worst_phase"
]
def test_loaded_scheduler_reports_all_latency_classes() -> None:
result = lab049.loaded_scheduler_scenario(_frame_duration(100_000))
assert result["video_queue_saturated"]
assert set(result["latency"]) == {
"EMERGENCY",
"CONTROL",
"TELEMETRY",
"VIDEO",
}
for statistics in result["latency"].values():
assert statistics["count"] > 0
assert 0.0 <= statistics["min_ms"] <= statistics["median_ms"] <= statistics["max_ms"]
def test_scheduler_preserves_valid_control_and_emergency_payloads() -> None:
control_payload = lab049._control_payload(0)
emergency_payload = lab049._emergency_payload(0)
control = decode_message(control_payload)
emergency = decode_message(emergency_payload)
assert isinstance(control, ControlStateMessage)
assert isinstance(emergency, EmergencyStop)
assert len(control_payload) < lab049.lab046.PACKET_PAYLOAD_SIZE
assert len(emergency_payload) < lab049.lab046.PACKET_PAYLOAD_SIZE
queued = (
QueuedPacket(
lab049._link_packet(TrafficClass.CONTROL, 0, 0.0, control_payload),
0,
),
QueuedPacket(
lab049._link_packet(TrafficClass.EMERGENCY, 0, 0.0, emergency_payload),
1,
),
)
schedule = lab049._schedule_fixed_physical_frames(
queued,
_frame_duration(100_000),
)
restored_payloads = {
item.queued.packet.traffic_class: decode_link_packet(item.wire_packet).payload
for item in schedule.transmitted
}
assert restored_payloads[TrafficClass.CONTROL] == control_payload
assert restored_payloads[TrafficClass.EMERGENCY] == emergency_payload
result = lab049.loaded_scheduler_scenario(_frame_duration(100_000))
assert not result["application_payloads_padded"]
assert result["fixed_physical_frame_duration_model"]
def test_scheduler_reports_expired_control_without_claiming_it_was_dropped() -> None:
result = lab049.loaded_scheduler_scenario(_frame_duration(50_000))
assert result["deadline_misses"]["CONTROL"] >= 1
assert not result["scheduler_drops_expired_packets"]
def test_channel_budget_uses_control_telemetry_before_video() -> None:
slow = lab049.channel_budget(
_frame_duration(20_000),
lab049.EXPECTED_JPEG_BYTES,
36,
)
fast = lab049.channel_budget(
_frame_duration(200_000),
lab049.EXPECTED_JPEG_BYTES,
36,
)
middle = lab049.channel_budget(
_frame_duration(100_000),
lab049.EXPECTED_JPEG_BYTES,
36,
)
assert slow["video_available_bitrate_bps"] == 0.0
assert middle["video_available_bitrate_bps"] == 0.0
assert fast["video_available_bitrate_bps"] > 0.0
assert middle["control_reserved_link_bps"] == pytest.approx(46_080.0)
assert middle["telemetry_load_link_bps"] == pytest.approx(23_040.0)
assert middle["fixed_physical_frame_per_packet"]
assert middle["service_requested_airtime_fraction"] > 1.0
assert fast["service_requested_airtime_fraction"] < 1.0
assert fast["video_available_frames_per_second"] == pytest.approx(
fast["physical_frame_capacity_per_second"] - 30.0
)
assert fast["remaining_link_bps"] == pytest.approx(
fast["scheduler_link_bitrate_bps"]
- fast["control_reserved_link_bps"]
- fast["telemetry_load_link_bps"]
)
def test_receiver_real_time_ratio_calculation() -> None:
assert lab049.processing_to_capture_ratio(0.5, 1.0) == 0.5
with pytest.raises(ValueError):
lab049.processing_to_capture_ratio(1.0, 0.0)
def test_noise_is_normalized_by_transmitted_bit_energy() -> None:
long = np.ones(400, dtype=np.complex64)
short = np.ones(160, dtype=np.complex64)
_, long_sigma = lab049.add_coded_bit_ebn0_noise(long, 1, 7.0, 49)
_, short_sigma = lab049.add_coded_bit_ebn0_noise(short, 1, 7.0, 49)
assert long_sigma / short_sigma == pytest.approx(np.sqrt(400 / 160))
def test_receiver_api_has_no_tx_plan_or_payload_oracle() -> None:
parameters = inspect.signature(lab049.receive_iq_frame).parameters
assert tuple(parameters) == ("iq_samples", "mode")
def test_mode_classification_requires_all_latency_capacity_and_speed_gates() -> None:
assert lab049.classify_mode(True, True, True, True, False, False) == "PARTIAL"
assert lab049.classify_mode(True, True, False, True, True, True) == "PARTIAL"
assert lab049.classify_mode(True, True, True, True, True, True) == "PASS"
assert lab049.classify_mode(False, True, True, True, True, True) == "FAIL"

View File

@@ -0,0 +1,119 @@
"""Проверки расчётов Lab049 без файлов эксперимента и оборудования."""
from __future__ import annotations
from dataclasses import replace
import pytest
from experiments import lab049_crsf_elrs_budget as budget
@pytest.fixture(scope="module")
def accounting() -> budget.ImageAccounting:
return budget.image_accounting(bytes(8724))
@pytest.mark.parametrize("interval_us, expected_ms", [(20_000, 20), (10_000, 10), (6666, 6.666), (3003, 3.003)])
def test_source_interval_preserves_integer_microseconds(interval_us, expected_ms):
mode = budget.ElrsMode("test", interval_us, 8, 16)
result = budget.elrs_budget(mode, None)
assert result.packet_interval_ms == expected_ms
assert result.rf_slot_rate_hz == pytest.approx(1_000_000 / interval_us)
assert result.next_rc_opportunity_wait_mean_ms == pytest.approx(expected_ms / 2)
assert result.next_rc_opportunity_wait_supremum_ms == expected_ms
@pytest.mark.parametrize("denominator", budget.TELEMETRY_DENOMINATORS)
def test_telemetry_and_waits_against_enumerated_cycle(denominator):
result = budget.elrs_budget(budget.ELRS_MODES[3], denominator)
# Вторая независимая форма: перечислить моменты RC и интегрировать
# треугольники ожидания между ними за полный период.
rc_times = [slot * 10 for slot in range(denominator) if slot != denominator - 1]
gaps = [right - left for left, right in zip(rc_times, rc_times[1:] + [denominator * 10])]
assert result.next_rc_opportunity_wait_mean_ms == pytest.approx(sum(gap**2 / 2 for gap in gaps) / (denominator * 10))
assert result.next_rc_opportunity_wait_supremum_ms == max(gaps)
assert result.telemetry_slot_fraction == 1 / denominator
assert result.telemetry_slots_per_second == 100 / denominator
assert result.rc_opportunities_per_second_upper_bound == len(rc_times) / (denominator / 100)
def test_off_is_zero_telemetry_and_not_ratio_one():
result = budget.elrs_budget(budget.ELRS_MODES[3], None)
assert result.telemetry_slots_per_second == result.data_ceiling_bytes_per_second == 0
assert result.data_ceiling_with_link_stats_bytes_per_second == 0
assert result.rc_opportunities_per_second_upper_bound == 100
with pytest.raises(ValueError):
budget.elrs_budget(budget.ELRS_MODES[3], 1)
def test_data_capacity_uses_data_not_whole_ota_packet():
result = budget.elrs_budget(budget.ELRS_MODES[3], 2)
assert result.data_ceiling_bytes_per_second == 500
assert result.data_burst_packets == 24
assert result.data_ceiling_with_link_stats_bytes_per_second == 492
maximum = budget.elrs_budget(budget.ELRS_MODES[-1], 2)
assert maximum.data_ceiling_bytes_per_second == 5000
assert 2 * maximum.data_ceiling_bytes_per_second < 8724 * 3
def test_repeated_packets_are_not_counted_as_new_commands():
with pytest.raises(ValueError, match="D/DK"):
budget.elrs_budget(replace(budget.ELRS_MODES[-1], repetitions=4), 128)
def test_actual_transport_overhead(accounting):
assert accounting.rf_frame_count == 36
assert accounting.fragment_header_bytes_total == 432
assert accounting.padding_bytes_total == 60
assert accounting.packet_bytes_total == 9648
assert accounting.packet_overhead_bytes_total == 432
assert accounting.fec_coded_bits_total == 154800
assert accounting.phy_header_bits_total == 5184
assert accounting.preamble_bits_total == 46080
assert accounting.transmitted_bpsk_bits_total == 206064
assert accounting.guard_and_filter_intervals_total == 1512
assert accounting.symbol_intervals_total == 207576
@pytest.mark.parametrize("fps,payload_bits,symbols", [(3, 209376, 622728), (5, 348960, 1037880), (10, 697920, 2075760)])
def test_targets(accounting, fps, payload_bits, symbols):
result = budget.video_requirement(accounting, fps)
assert budget.jpeg_payload_bitrate(8724, fps) == payload_bits
assert result["payload_kbit_per_second"] == payload_bits / 1000
assert result["minimum_phy_symbol_rate_including_guards"] == symbols
assert result["rx_rf_frames_per_second_minimum"] == 36 * fps
assert result["rx_service_budget_ms_per_image"] == 1000 / fps
assert not result["e2e_limit_is_approved_or_measured"]
@pytest.mark.parametrize("speed,fps,distance", [(10, 3, 25/27), (15, 5, 5/6), (25, 10, 25/36), (0, 1, 0)])
def test_distance(speed, fps, distance):
assert budget.distance_per_frame(speed, fps) == pytest.approx(distance)
@pytest.mark.parametrize("symbol_rate,airtime", [(20000, 10.3788), (50000, 4.15152), (100000, 2.07576), (200000, 1.03788)])
def test_sdr_capacity_reserves_no_elrs_traffic(accounting, symbol_rate, airtime):
result = budget.sdr_capacity(accounting, symbol_rate)
assert result["jpeg_airtime_seconds"] == pytest.approx(airtime)
assert result["ideal_video_fps"] == pytest.approx(1 / airtime)
assert result["ideal_payload_kbit_per_second"] == pytest.approx(8724 * 8 / airtime / 1000)
assert result["sdr_rc_reserved_symbol_intervals_per_second"] == 0
half = budget.sdr_capacity(accounting, symbol_rate, bulk_symbol_intervals_per_second=symbol_rate/2)
assert half["ideal_video_fps"] == pytest.approx(result["ideal_video_fps"] / 2)
@pytest.mark.parametrize("bad", [0, -1, float("inf"), float("nan"), True])
def test_invalid_rates_rejected(accounting, bad):
with pytest.raises(ValueError):
budget.jpeg_payload_bitrate(8724, bad)
with pytest.raises(ValueError):
budget.distance_per_frame(25, bad)
with pytest.raises(ValueError):
budget.sdr_capacity(accounting, bad)
@pytest.mark.parametrize("bad", [True, 16.0, 3, 0])
def test_invalid_telemetry_ratios_rejected(bad):
with pytest.raises(ValueError):
budget.elrs_budget(budget.ELRS_MODES[1], bad)

View File

@@ -0,0 +1,66 @@
"""Регрессии неизвестной границы, CFO и полной задержки доставки Lab049."""
from __future__ import annotations
import numpy as np
import pytest
from experiments import lab049_bpsk_symbol_rate as lab
from experiments import lab049_independent_iq as independent
def test_fractional_symbol_rate_is_not_silently_rounded() -> None:
with pytest.raises(ValueError):
lab.phy_mode(20000.5)
@pytest.mark.parametrize("rate", (50_000, 100_000))
def test_unknown_frame_start_and_residual_cfo(rate: int) -> None:
mode = lab.phy_mode(rate)
payload = bytes(range(256))
plan = lab.lab045.build_transmission_plan(payload)
iq = lab.lab045.lab044.signed_iq_bytes_to_complex64(
lab.build_parameterized_tx_cs8(plan, mode))
iq *= np.exp(1j * (2.7 + 2 * np.pi * 10 * np.arange(iq.size)
/ mode.sample_rate_hz)).astype(np.complex64)
stream = np.pad(iq, (12007, 12003))
result = independent.receive_stream(stream, mode)
assert result.termination_reason == "END_OF_STREAM"
assert result.crc_pass_count == 1
assert result.accepted_frames[0].reception.payload_bytes == payload
assert result.accepted_frames[0].estimated_cfo_hz == pytest.approx(10, abs=1)
assert lab.lab045.SYMBOL_RATE == 20_000
assert lab.lab045.lab044.lab043.SAMPLES_PER_SYMBOL == 400
def test_receiver_mode_restores_constants_on_exception() -> None:
with pytest.raises(RuntimeError):
with independent.receiver_mode(lab.phy_mode(100_000)):
raise RuntimeError("контроль выхода")
assert lab.lab045.SAMPLES_PER_SYMBOL == 400
assert lab.lab045.lab044.SAMPLES_PER_SYMBOL == 400
def test_noise_only_does_not_produce_a_packet() -> None:
generator = np.random.default_rng(49)
noise = (generator.standard_normal(180000)
+ 1j * generator.standard_normal(180000)).astype(np.complex64)
result = independent.receive_stream(noise, lab.phy_mode(100_000))
assert result.crc_pass_count == 0
def test_emergency_delivery_includes_its_own_frame() -> None:
probe = lab.priority_probe(0.02883)
assert probe["emergency_max_video_blocking_ms"] == pytest.approx(28.83)
assert probe["emergency_worst_delivery_ms"] == pytest.approx(57.66)
assert not probe["emergency_delivery_deadline_met_in_worst_phase"]
def test_deadlines_use_complete_delivery_and_drain_is_separate() -> None:
result = lab.loaded_scheduler_scenario(0.05766)
assert result["deadline_misses"]["CONTROL"] == 6
assert result["deadline_misses"]["EMERGENCY"] == 1
assert result["active_window_delivered_packets"]["TELEMETRY"] == 0
assert result["active_window_delivery_latency"]["TELEMETRY"]["count"] == 0
assert result["delivery_latency_until_drain_end"]["CONTROL"]["max_ms"] == pytest.approx(105.64)
assert result["drain_end_seconds"] > result["source_duration_seconds"]